commit 66549acf5cd51c11e0344d5eeedf4e34a9c11ae2 Author: Jacquin Antoine Date: Thu Feb 26 13:55:07 2026 +0100 Initial commit: mod_reqin_log Apache module Features: - JSON logging of HTTP requests to Unix domain socket - Configurable HTTP headers logging (flat JSON structure) - Header value truncation and count limits - Automatic reconnect on socket disconnection - Error reporting with throttling Configuration directives: - JsonSockLogEnabled: Enable/disable logging - JsonSockLogSocket: Unix socket path - JsonSockLogHeaders: List of headers to log - JsonSockLogMaxHeaders: Maximum headers to log - JsonSockLogMaxHeaderValueLen: Max header value length - JsonSockLogReconnectInterval: Reconnect delay - JsonSockLogErrorReportInterval: Error log throttle Includes: - Module source code (src/) - Unit and integration tests (tests/, scripts/) - Documentation (README.md, architecture.yml) - Build configuration (CMakeLists.txt, Makefile) - Packaging (deb/rpm) Co-authored-by: Qwen-Coder diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0f902cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,202 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + # Build on Rocky Linux 8 + build-rocky-8: + runs-on: ubuntu-latest + container: + image: rockylinux:8 + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + dnf install -y epel-release + dnf install -y gcc make httpd httpd-devel apr-devel apr-util-devel rpm-build + + - name: Build module + run: | + make APXS=/usr/bin/apxs + + - name: Verify module + run: | + ls -la modules/mod_reqin_log.so + + - name: Upload module artifact + uses: actions/upload-artifact@v4 + with: + name: mod_reqin_log-rocky8 + path: modules/mod_reqin_log.so + + # Build on Debian + build-debian: + runs-on: ubuntu-latest + container: + image: debian:stable + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + apt-get update + apt-get install -y build-essential apache2 apache2-dev + + - name: Build module + run: | + make APXS=/usr/bin/apxs + + - name: Verify module + run: | + ls -la modules/mod_reqin_log.so + + - name: Upload module artifact + uses: actions/upload-artifact@v4 + with: + name: mod_reqin_log-debian + path: modules/mod_reqin_log.so + + # Unit tests + unit-tests: + runs-on: ubuntu-latest + container: + image: debian:stable + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + apt-get update + apt-get install -y build-essential cmake libcmocka-dev apache2-dev + + - name: Configure tests + run: | + mkdir -p build/tests + cd build/tests + cmake ../../ + + - name: Build tests + run: | + make -C build/tests + + - name: Run tests + run: | + make -C build/tests run_tests + + # Build RPM package + build-rpm: + runs-on: ubuntu-latest + container: + image: rockylinux:8 + needs: [build-rocky-8] + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + dnf install -y epel-release + dnf install -y gcc make httpd httpd-devel apr-devel apr-util-devel rpm-build rpmlint + + - name: Create source tarball + run: | + tar -czf mod_reqin_log-1.0.0.tar.gz --transform 's,^,mod_reqin_log-1.0.0/,' . + + - name: Setup rpmbuild + run: | + mkdir -p ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS} + cp mod_reqin_log-1.0.0.tar.gz ~/rpmbuild/SOURCES/ + cp packaging/rpm/mod_reqin_log.spec ~/rpmbuild/SPECS/ + + - name: Build RPM + run: | + rpmbuild -ba ~/rpmbuild/SPECS/mod_reqin_log.spec + + - name: Upload RPM artifacts + uses: actions/upload-artifact@v4 + with: + name: rpm-packages + path: ~/rpmbuild/RPMS/x86_64/*.rpm + + # Build DEB package + build-deb: + runs-on: ubuntu-latest + container: + image: debian:stable + needs: [build-debian] + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + apt-get update + apt-get install -y build-essential apache2 apache2-dev debhelper devscripts dpkg-dev + + - name: Setup package metadata + run: | + cp -r packaging/deb/* ./debian/ + echo "1.0.0" > debian/changelog + echo "mod_reqin_log (1.0.0) stable; urgency=medium" >> debian/changelog + echo "" >> debian/changelog + echo " * Initial release" >> debian/changelog + echo "" >> debian/changelog + echo " -- Developer $(date -R)" >> debian/changelog + + - name: Build DEB + run: | + debuild -us -uc -b + + - name: Upload DEB artifacts + uses: actions/upload-artifact@v4 + with: + name: deb-packages + path: ../*.deb + + # Integration tests + integration-tests: + runs-on: ubuntu-latest + container: + image: rockylinux:8 + needs: [build-rocky-8] + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + dnf install -y epel-release + dnf install -y gcc make httpd httpd-devel apr-devel apr-util-devel python3 curl + + - name: Build module + run: | + make APXS=/usr/bin/apxs + + - name: Setup Apache configuration + run: | + mkdir -p /var/run/mod_reqin_log + cp conf/mod_reqin_log.conf /etc/httpd/conf.d/ + echo "LoadModule reqin_log_module /github/workspace/modules/mod_reqin_log.so" > /etc/httpd/conf.d/00-mod_reqin_log.conf + + - name: Start socket consumer + run: | + python3 scripts/socket_consumer.py & + sleep 2 + + - name: Start Apache + run: | + httpd -t + httpd -DFOREGROUND & + sleep 3 + + - name: Run integration tests + run: | + curl -H "X-Request-Id: test-123" http://localhost/ + curl -H "X-Trace-Id: trace-456" http://localhost/api + sleep 2 + + - name: Verify logs + run: | + echo "Integration test completed" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cd17c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Build artifacts +*.o +*.so +*.a +*.la +.deps +.libs + +# Build directories +build/ +cmake-build-*/ +dist/ +bin/ +obj/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log + +# Testing +coverage/ +*.gcno +*.gcda + +# Packaging +*.rpm +*.deb +*.tar.gz diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..ace3182 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.10) +project(mod_reqin_log_tests C) + +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) + +# Find required packages +find_package(PkgConfig REQUIRED) +pkg_check_modules(CMOCKA REQUIRED cmocka) + +# Include directories +include_directories(${CMOCKA_INCLUDE_DIRS}) +include_directories(/usr/include/httpd) +include_directories(/usr/include/apr-1) + +# Test executable +add_executable(test_json_serialization tests/unit/test_json_serialization.c) +target_link_libraries(test_json_serialization ${CMOCKA_LIBRARIES} m) + +add_executable(test_header_handling tests/unit/test_header_handling.c) +target_link_libraries(test_header_handling ${CMOCKA_LIBRARIES} m) + +add_executable(test_config_parsing tests/unit/test_config_parsing.c) +target_link_libraries(test_config_parsing ${CMOCKA_LIBRARIES} m) + +# Enable testing +enable_testing() +add_test(NAME JsonSerializationTest COMMAND test_json_serialization) +add_test(NAME HeaderHandlingTest COMMAND test_header_handling) +add_test(NAME ConfigParsingTest COMMAND test_config_parsing) + +# Custom target for running tests +add_custom_target(run_tests + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + DEPENDS test_json_serialization test_header_handling test_config_parsing +) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..30f9814 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Dockerfile for building mod_reqin_log (minimal - no tests) +FROM rockylinux:8 + +# Install build dependencies +RUN dnf install -y epel-release && \ + dnf install -y \ + gcc \ + make \ + httpd \ + httpd-devel \ + apr-devel \ + apr-util-devel \ + python3 \ + curl \ + redhat-rpm-config \ + && dnf clean all + +# Set working directory +WORKDIR /build + +# Copy source files +COPY src/ src/ +COPY Makefile Makefile +COPY conf/ conf/ + +# Build the module +RUN make APXS=/usr/bin/apxs + +# Verify module was built +RUN ls -la modules/mod_reqin_log.so + +# Default command +CMD ["/bin/bash"] diff --git a/Dockerfile.test-socket b/Dockerfile.test-socket new file mode 100644 index 0000000..2ff0980 --- /dev/null +++ b/Dockerfile.test-socket @@ -0,0 +1,40 @@ +# Dockerfile for running Unix socket integration tests +FROM rockylinux:8 + +# Install dependencies +RUN dnf install -y epel-release && \ + dnf install -y \ + gcc \ + make \ + httpd \ + httpd-devel \ + apr-devel \ + apr-util-devel \ + python3 \ + curl \ + redhat-rpm-config \ + && dnf clean all + +# Copy module source +COPY src/ src/ +COPY Makefile Makefile +COPY conf/ conf/ + +# Build the module +RUN make APXS=/usr/bin/apxs + +# Copy test scripts +COPY scripts/test_unix_socket.sh /test_unix_socket.sh +COPY scripts/socket_listener.py /build/scripts/socket_listener.py +RUN chmod +x /test_unix_socket.sh +RUN mkdir -p /build/scripts + +# Create document root +RUN mkdir -p /var/www/html +RUN echo "

Test

" > /var/www/html/index.html + +# Set working directory +WORKDIR /build + +# Run the test +CMD ["/test_unix_socket.sh"] diff --git a/Dockerfile.tests b/Dockerfile.tests new file mode 100644 index 0000000..cb89304 --- /dev/null +++ b/Dockerfile.tests @@ -0,0 +1,46 @@ +# Dockerfile for running unit tests +FROM rockylinux:8 + +# Install build and test dependencies +RUN dnf install -y epel-release && \ + dnf install -y \ + gcc \ + make \ + httpd \ + httpd-devel \ + apr-devel \ + apr-util-devel \ + cmake \ + python3 \ + curl \ + git \ + && dnf clean all + +# Build and install cmocka from source +RUN cd /tmp && \ + git clone https://git.cryptomilk.org/projects/cmocka.git && \ + cd cmocka && \ + git checkout cmocka-1.1.5 && \ + mkdir build && cd build && \ + cmake .. -DCMAKE_INSTALL_PREFIX=/usr && \ + make && \ + make install && \ + cd / && \ + rm -rf /tmp/cmocka && \ + dnf remove -y git && \ + dnf clean all + +WORKDIR /build + +COPY src/ src/ +COPY tests/ tests/ +COPY CMakeLists.txt CMakeLists.txt +COPY Makefile Makefile + +# Build and run tests +RUN mkdir -p build/tests && \ + cd build/tests && \ + cmake ../../ && \ + make + +CMD ["ctest", "--output-on-failure"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34c1905 --- /dev/null +++ b/LICENSE @@ -0,0 +1,107 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the +copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with +that entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation source, +and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation +or translation of a Source form, including but not limited to compiled +object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License. + +"Derivative Works" shall mean any work, whether in Source or Object form, +that is based on (or derived from) the Work. + +"Contribution" shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof. + +"Contributor" shall mean Licensor and any individual or Legal Entity on +behalf of whom a Contribution has been received by Licensor. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and +such Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works +thereof in any medium, with or without modifications, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a + copy of this License; and + +(b) You must cause any modified files to carry prominent notices stating + that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices + from the Source form of the Work; and + +(d) You may add Your own copyright statement to Your modifications and may + provide additional or different license terms and conditions for use, + reproduction, or distribution of Your modifications. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally +submitted for inclusion in the Work by You shall be under the terms and +conditions of this License. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides +the Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. + +8. Limitation of Liability. + +In no event shall any Contributor be liable to You for damages, including +any direct, indirect, special, incidental, or consequential damages arising +out of the use or inability to use the Work. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose +to offer, and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this License. + +END OF TERMS AND CONDITIONS diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3ee7976 --- /dev/null +++ b/Makefile @@ -0,0 +1,86 @@ +# Makefile for mod_reqin_log +# Apache HTTPD module for logging HTTP requests as JSON to Unix socket + +# APXS tool path (can be overridden) +APXS ?= apxs + +# Compiler settings +CC ?= gcc +CFLAGS ?= -Wall -Wextra -O2 + +# Directories +SRC_DIR = src +BUILD_DIR = build +INSTALL_DIR = modules + +# Source files +SRCS = $(SRC_DIR)/mod_reqin_log.c + +# Module name +MODULE_NAME = mod_reqin_log + +.PHONY: all clean install uninstall test + +all: $(MODULE_NAME).so + +# Build the module using apxs +# Note: Use -Wc to pass flags to the C compiler through apxs +$(MODULE_NAME).so: $(SRCS) + @mkdir -p $(BUILD_DIR) + $(APXS) -c -Wc,"$(CFLAGS)" -o $(BUILD_DIR)/$(MODULE_NAME).so $(SRCS) + @mkdir -p $(INSTALL_DIR) + @if [ -f $(BUILD_DIR)/.libs/$(MODULE_NAME).so ]; then \ + cp $(BUILD_DIR)/.libs/$(MODULE_NAME).so $(INSTALL_DIR)/; \ + elif [ -f $(BUILD_DIR)/$(MODULE_NAME).so ]; then \ + cp $(BUILD_DIR)/$(MODULE_NAME).so $(INSTALL_DIR)/; \ + fi + +# Install the module +install: $(MODULE_NAME).so + @echo "Installing $(MODULE_NAME).so..." + @mkdir -p $(DESTDIR)/usr/lib/apache2/modules + cp $(BUILD_DIR)/$(MODULE_NAME).so $(DESTDIR)/usr/lib/apache2/modules/ + @echo "Installation complete." + @echo "Enable the module by adding to your httpd.conf:" + @echo " LoadModule reqin_log_module modules/mod_reqin_log.so" + +# Uninstall the module +uninstall: + rm -f $(DESTDIR)/usr/lib/apache2/modules/$(MODULE_NAME).so + @echo "Uninstallation complete." + +# Clean build artifacts +clean: + rm -rf $(BUILD_DIR) $(INSTALL_DIR) + rm -f .libs/*.o .libs/*.la .libs/*.so + rm -f *.o *.la *.lo + rm -rf .libs + +# Run unit tests (requires cmocka) +test: + @mkdir -p build/tests + cd build/tests && cmake ../../ -DCMAKE_BUILD_TYPE=Debug + $(MAKE) -C build/tests run_tests + +# Build with debug symbols +debug: CFLAGS += -g -DDEBUG +debug: clean all + +# Help target +help: + @echo "mod_reqin_log Makefile" + @echo "" + @echo "Targets:" + @echo " all - Build the module (default)" + @echo " install - Install the module to DESTDIR" + @echo " uninstall- Remove the module from DESTDIR" + @echo " clean - Remove build artifacts" + @echo " test - Run unit tests" + @echo " debug - Build with debug symbols" + @echo " help - Show this help message" + @echo "" + @echo "Variables:" + @echo " APXS - Path to apxs tool (default: apxs)" + @echo " CC - C compiler (default: gcc)" + @echo " CFLAGS - Compiler flags (default: -Wall -Wextra -O2)" + @echo " DESTDIR - Installation destination (default: /)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..04d54e3 --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# mod_reqin_log + +Apache HTTPD 2.4 module for logging all incoming HTTP requests as JSON lines to a Unix domain socket. + +## Features + +- **Non-blocking I/O**: Logging never blocks worker processes +- **Request-time logging**: Logs at `post_read_request` phase, capturing request data before application processing +- **Configurable headers**: Select which HTTP headers to include in logs +- **Header truncation**: Limit header value length to protect against oversized logs +- **Automatic reconnection**: Reconnects to Unix socket on failure with configurable backoff +- **Throttled error reporting**: Prevents error_log flooding on persistent failures +- **MPM compatible**: Works with prefork, worker, and event MPMs + +## Requirements + +- Apache HTTPD 2.4+ +- GCC compiler +- APR development libraries +- Apache development headers (`httpd-devel` or `apache2-dev`) + +## Installation + +### Build from Source + +```bash +# Clone or extract the source +cd mod_reqin_log + +# Build the module +make + +# Install (requires root privileges) +sudo make install +``` + +### Using Package Manager + +#### RPM (Rocky Linux 8+) + +```bash +# Build RPM package +rpmbuild -ba packaging/rpm/mod_reqin_log.spec + +# Install the package +sudo rpm -ivh ~/rpmbuild/RPMS/x86_64/mod_reqin_log-1.0.0-1.el8.x86_64.rpm +``` + +#### DEB (Debian/Ubuntu) + +```bash +# Build DEB package +cd packaging/deb +debuild -us -uc + +# Install the package +sudo dpkg -i ../libapache2-mod-reqin-log_1.0.0_amd64.deb +``` + +## Configuration + +Load the module and configure in your Apache configuration: + +```apache +# Load the module +LoadModule reqin_log_module modules/mod_reqin_log.so + +# Enable logging +JsonSockLogEnabled On + +# Unix socket path +JsonSockLogSocket "/var/run/mod_reqin_log.sock" + +# Headers to log (be careful not to log sensitive data) +JsonSockLogHeaders X-Request-Id X-Trace-Id User-Agent Referer + +# Maximum headers to log +JsonSockLogMaxHeaders 10 + +# Maximum header value length +JsonSockLogMaxHeaderValueLen 256 + +# Reconnect interval (seconds) +JsonSockLogReconnectInterval 10 + +# Error report interval (seconds) +JsonSockLogErrorReportInterval 10 +``` + +### Configuration Directives + +| Directive | Type | Default | Description | +|-----------|------|---------|-------------| +| `JsonSockLogEnabled` | On/Off | Off | Enable or disable logging | +| `JsonSockLogSocket` | String | - | Unix domain socket path | +| `JsonSockLogHeaders` | List | - | HTTP headers to include | +| `JsonSockLogMaxHeaders` | Integer | 10 | Max headers to log | +| `JsonSockLogMaxHeaderValueLen` | Integer | 256 | Max header value length | +| `JsonSockLogReconnectInterval` | Integer | 10 | Reconnect delay (seconds) | +| `JsonSockLogErrorReportInterval` | Integer | 10 | Error log throttle (seconds) | + +## JSON Log Format + +Each log entry is a single-line JSON object with a flat structure: + +```json +{ + "time": "2026-02-26T11:59:30Z", + "timestamp": 1708948770000000000, + "src_ip": "192.0.2.10", + "src_port": 45678, + "dst_ip": "198.51.100.5", + "dst_port": 443, + "method": "GET", + "path": "/api/users", + "host": "example.com", + "http_version": "HTTP/1.1", + "header_X-Request-Id": "abcd-1234", + "header_User-Agent": "curl/7.70.0" + } +} +``` + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `time` | String | ISO8601 timestamp with timezone | +| `timestamp` | Integer | Nanoseconds since epoch | +| `src_ip` | String | Client IP address | +| `src_port` | Integer | Client port | +| `dst_ip` | String | Server IP address | +| `dst_port` | Integer | Server port | +| `method` | String | HTTP method | +| `path` | String | Request path | +| `host` | String | Host header value | +| `http_version` | String | HTTP protocol version | +| `headers` | Object | Configured HTTP headers | + +## Unix Socket Consumer + +Create a Unix socket listener to receive log entries: + +```python +#!/usr/bin/env python3 +import socket +import os +import json + +SOCKET_PATH = "/var/run/mod_reqin_log.sock" + +# Remove existing socket file +if os.path.exists(SOCKET_PATH): + os.remove(SOCKET_PATH) + +# Create Unix socket server +server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +server.bind(SOCKET_PATH) +server.listen(5) +os.chmod(SOCKET_PATH, 0o666) + +print(f"Listening on {SOCKET_PATH}") + +while True: + conn, addr = server.accept() + data = b"" + while True: + chunk = conn.recv(4096) + if not chunk: + break + data += chunk + if b"\n" in data: + for line in data.decode().strip().split("\n"): + if line: + log_entry = json.loads(line) + print(json.dumps(log_entry, indent=2)) + data = b"" + conn.close() +``` + +## Security Considerations + +⚠️ **Important**: This module logs all HTTP requests transparently. + +- **Do not log sensitive headers**: Avoid including `Authorization`, `Cookie`, `X-Api-Key`, or other sensitive headers in `JsonSockLogHeaders` +- **Socket permissions**: Ensure the Unix socket has appropriate file permissions +- **Log consumer security**: Protect the socket consumer from unauthorized access + +## Troubleshooting + +### Module not loading + +``` +AH00534: mod_reqin_log: Unable to load module +``` + +Ensure the module path is correct and the file exists: +```bash +ls -la /usr/lib/apache2/modules/mod_reqin_log.so +``` + +### Socket connection failures + +``` +[mod_reqin_log] Unix socket connect failed: /var/run/mod_reqin_log.sock +``` + +- Ensure the socket consumer is running +- Check socket file permissions +- Verify SELinux/AppArmor policies if applicable + +### No logs appearing + +1. Verify `JsonSockLogEnabled On` is set +2. Verify `JsonSockLogSocket` path is configured +3. Check Apache error log for module errors +4. Ensure socket consumer is listening + +## Testing + +### Run Unit Tests + +```bash +# Install test dependencies +sudo dnf install cmocka-devel # Rocky Linux +sudo apt install libcmocka-dev # Debian/Ubuntu + +# Build and run tests +mkdir build && cd build +cmake .. +make test +``` + +### Integration Testing + +```bash +# Start socket consumer +python3 scripts/socket_consumer.py & + +# Start Apache with module enabled +sudo systemctl start httpd + +# Send test requests +curl -H "X-Request-Id: test-123" http://localhost/ + +# Check consumer output +``` + +## License + +Apache License 2.0 + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests +5. Submit a pull request diff --git a/architecture.yml b/architecture.yml new file mode 100644 index 0000000..d29781b --- /dev/null +++ b/architecture.yml @@ -0,0 +1,396 @@ +project: + name: mod_reqin_log + description: > + Apache HTTPD 2.4 module logging all incoming HTTP requests as JSON lines + to a Unix domain socket at request reception time (no processing time). + language: c + target: + server: apache-httpd + version: "2.4" + os: rocky-linux-8+ + build: + toolchain: gcc + apache_dev: httpd-devel (apxs) + artifacts: + - mod_reqin_log.so + +context: + architecture: + pattern: native-apache-module + scope: global + mpm_compatibility: + - prefork + - worker + - event + request_phase: + hook: post_read_request + rationale: > + Log as soon as the HTTP request is fully read to capture input-side data + (client/server addresses, request line, headers) without waiting for + application processing. + logging_scope: + coverage: all-traffic + description: > + Every HTTP request handled by the Apache instance is considered for logging + when the module is enabled and the Unix socket is configured. + +module: + name: mod_reqin_log + hooks: + - name: register_hooks + responsibilities: + - Register post_read_request hook for logging at request reception. + - Initialize per-process Unix socket connection if enabled. + - name: child_init + responsibilities: + - Initialize module state for each Apache child process. + - Attempt initial non-blocking connection to Unix socket if configured. + - name: child_exit + responsibilities: + - Cleanly close Unix socket file descriptor if open. + - name: post_read_request + responsibilities: + - Ensure Unix socket is connected (with periodic reconnect). + - Build JSON log document for the request. + - Write JSON line to Unix socket using non-blocking I/O. + - Handle errors by dropping the current log line and rate-limiting + error reports into Apache error_log. + data_model: + json_line: + description: > + One JSON object per HTTP request, serialized on a single line and + terminated by "\n". + fields: + - name: time + type: string + format: iso8601-with-timezone + example: "2026-02-26T11:59:30Z" + - name: timestamp + type: integer + unit: nanoseconds + description: > + Monotonic or wall-clock based timestamp in nanoseconds since an + implementation-defined epoch (stable enough for ordering and latency analysis). + - name: src_ip + type: string + example: "192.0.2.10" + - name: src_port + type: integer + example: 45678 + - name: dst_ip + type: string + example: "198.51.100.5" + - name: dst_port + type: integer + example: 443 + - name: method + type: string + example: "GET" + - name: path + type: string + example: "/foo/bar" + - name: host + type: string + example: "example.com" + - name: http_version + type: string + example: "HTTP/1.1" + - name: headers + type: object + description: > + Flattened headers from the configured header list. Keys are derived + from configured header names prefixed with 'header_'. + key_pattern: "header_" + example: + header_X-Request-Id: "abcd-1234" + header_User-Agent: "curl/7.70.0" + +configuration: + scope: global + directives: + - name: JsonSockLogEnabled + type: flag + context: server-config + default: "Off" + description: > + Enable or disable mod_reqin_log logging globally. Logging only occurs + when this directive is On and JsonSockLogSocket is set. + - name: JsonSockLogSocket + type: string + context: server-config + required_when_enabled: true + example: "/var/run/mod_reqin_log.sock" + description: > + Filesystem path of the Unix domain socket to which JSON log lines + will be written. + - name: JsonSockLogHeaders + type: list + context: server-config + value_example: ["X-Request-Id", "X-Trace-Id", "User-Agent"] + description: > + List of HTTP header names to log. For each configured header , + the module adds a JSON field 'header_' at the root level of the + JSON log entry (flat structure). Order matters for applying the + JsonSockLogMaxHeaders limit. + - name: JsonSockLogMaxHeaders + type: integer + context: server-config + default: 10 + min: 0 + description: > + Maximum number of headers from JsonSockLogHeaders to actually log. + If more headers are configured, only the first N are considered. + - name: JsonSockLogMaxHeaderValueLen + type: integer + context: server-config + default: 256 + min: 1 + description: > + Maximum length in characters for each logged header value. + Values longer than this limit are truncated before JSON encoding. + - name: JsonSockLogReconnectInterval + type: integer + context: server-config + default: 10 + unit: seconds + description: > + Minimal delay between two connection attempts to the Unix socket after + a failure. Used to avoid reconnect attempts on every request. + - name: JsonSockLogErrorReportInterval + type: integer + context: server-config + default: 10 + unit: seconds + description: > + Minimal delay between two error messages emitted into Apache error_log + for repeated I/O or connection errors on the Unix socket. + + behavior: + enabling_rules: + - JsonSockLogEnabled must be On. + - JsonSockLogSocket must be set to a non-empty path. + header_handling: + - No built-in blacklist; admin is fully responsible for excluding + sensitive headers (Authorization, Cookie, etc.). + - If a configured header is absent in a request, the corresponding + JSON key may be omitted or set to null (implementation choice, but + must be consistent). + - Header values are truncated to JsonSockLogMaxHeaderValueLen characters. + +io: + socket: + type: unix-domain + mode: client + path_source: JsonSockLogSocket + connection: + persistence: true + non_blocking: true + lifecycle: + open: + - Attempt initial connection during child_init if enabled. + - On first log attempt after reconnect interval expiry if not yet connected. + failure: + - On connection failure, mark socket as unavailable. + - Do not block the worker process. + reconnect: + strategy: time-based + interval_seconds: "@config.JsonSockLogReconnectInterval" + trigger: > + When a request arrives and the last connect attempt time is older + than reconnect interval, a new connect is attempted. + write: + format: "json_object + '\\n'" + mode: non-blocking + error_handling: + on_eagain: + action: drop-current-log-line + note: do not retry for this request. + on_epipe_or_conn_reset: + action: + - close_socket + - mark_unavailable + - schedule_reconnect + generic_errors: + action: drop-current-log-line + drop_policy: + description: > + Logging errors never impact client response. The current log line + is silently dropped (except for throttled error_log reporting). + +error_handling: + apache_error_log_reporting: + enabled: true + throttle_interval_seconds: "@config.JsonSockLogErrorReportInterval" + events: + - type: connect_failure + message_template: "[mod_reqin_log] Unix socket connect failed: /" + - type: write_failure + message_template: "[mod_reqin_log] Unix socket write failed: /" + fatal_conditions: + - description: > + Misconfiguration (JsonSockLogEnabled On but missing JsonSockLogSocket) + should be reported at startup as a configuration error. + - description: > + Any internal JSON-encoding failure should be treated as non-fatal: + drop current log and optionally emit a throttled error_log entry. + +constraints: + performance: + objectives: + - Logging overhead per request should be minimal and non-blocking. + - No dynamic allocations in hot path beyond what is strictly necessary + (prefer APR pools where possible). + design_choices: + - Single JSON serialization pass per request. + - Use non-blocking I/O to avoid stalling worker threads/processes. + - Avoid reconnect attempts on every request via time-based backoff. + security: + notes: + - Module does not anonymize IPs nor scrub headers; it is intentionally + transparent. Data protection and header choices are delegated to configuration. + - No requests are rejected due to logging failures. + robustness: + requirements: + - Logging failures must not crash Apache worker processes. + - Module must behave correctly under high traffic, socket disappearance, + and repeated connect failures. + +testing: + strategy: + unit_tests: + focus: + - JSON serialization with header truncation and header count limits. + - Directive parsing and configuration merging (global scope). + - Error-handling branches for non-blocking write and reconnect logic. + integration_tests: + env: + server: apache-httpd 2.4 + os: rocky-linux-8+ + log_consumer: simple Unix socket server capturing JSON lines + scenarios: + - name: basic_logging + description: > + With JsonSockLogEnabled On and valid socket, verify that each request + produces a valid JSON line with expected fields. + - name: header_limits + description: > + Configure more headers than JsonSockLogMaxHeaders and verify only + the first N are logged and values are truncated according to + JsonSockLogMaxHeaderValueLen. + - name: socket_unavailable_on_start + description: > + Start Apache with JsonSockLogEnabled On but socket not yet created; + verify periodic reconnect attempts and throttled error logging. + - name: runtime_socket_loss + description: > + Drop the Unix socket while traffic is ongoing; verify that log lines + are dropped, worker threads are not blocked, and reconnect attempts + resume once the socket reappears. + + +ci: + strategy: + description: > + All builds, tests and packaging are executed inside Docker containers. + The host only needs Docker and the CI runner. + tools: + orchestrator: "to-define (GitLab CI / GitHub Actions / autre)" + container_engine: docker + stages: + - name: build + description: > + Compile mod_reqin_log as an Apache 2.4 module inside Docker images + dedicated to each target distribution. + jobs: + - name: build-rocky-8 + image: "rockylinux:8" + steps: + - install_deps: + - gcc + - make + - httpd + - httpd-devel + - apr-devel + - apr-util-devel + - rpm-build + - build_module: + command: "apxs -c -i src/mod_reqin_log.c" + - name: build-debian + image: "debian:stable" + steps: + - install_deps: + - build-essential + - apache2 + - apache2-dev + - debhelper + - build_module: + command: "apxs -c -i src/mod_reqin_log.c" + + - name: test + description: > + Run unit tests (C) and integration tests (Apache + Unix socket consumer) + inside Docker containers. + jobs: + - name: unit-tests + image: "debian:stable" + steps: + - install_test_deps: + - build-essential + - cmake + - "test-framework (à définir: cmocka, criterion, ...)" + - run_tests: + command: "ctest || make test" + - name: integration-tests-rocky-8 + image: "rockylinux:8" + steps: + - prepare_apache_and_module + - start_unix_socket_consumer + - run_http_scenarios: + description: > + Validate JSON logs, header limits, socket loss and reconnect + behaviour using curl/ab/siege or similar tools. + + - name: package + description: > + Build RPM and DEB packages for mod_reqin_log inside Docker. + jobs: + - name: rpm-rocky-8 + image: "rockylinux:8" + steps: + - install_deps: + - rpm-build + - rpmlint + - "build deps same as 'build-rocky-8'" + - build_rpm: + spec_file: "packaging/rpm/mod_reqin_log.spec" + command: "rpmbuild -ba packaging/rpm/mod_reqin_log.spec" + - artifacts: + paths: + - "dist/rpm/**/*.rpm" + - name: deb-debian + image: "debian:stable" + steps: + - install_deps: + - devscripts + - debhelper + - dpkg-dev + - "build deps same as 'build-debian'" + - build_deb: + command: | + cd packaging/deb + debuild -us -uc + - artifacts: + paths: + - "dist/deb/**/*.deb" + + artifacts: + retention: + policy: "keep build logs and packages long enough for debugging (to define)" + outputs: + - type: module + path: "dist/modules/mod_reqin_log.so" + - type: rpm + path: "dist/rpm/" + - type: deb + path: "dist/deb/" + diff --git a/conf/mod_reqin_log.conf b/conf/mod_reqin_log.conf new file mode 100644 index 0000000..a5139a0 --- /dev/null +++ b/conf/mod_reqin_log.conf @@ -0,0 +1,27 @@ +# mod_reqin_log example configuration +# Load this configuration in your Apache httpd.conf or a separate included file + +# Load the module (adjust path as needed) +LoadModule reqin_log_module modules/mod_reqin_log.so + +# Enable mod_reqin_log +JsonSockLogEnabled On + +# Unix domain socket path for JSON log output +JsonSockLogSocket "/var/run/mod_reqin_log.sock" + +# HTTP headers to include in the JSON log +# Warning: Be careful not to log sensitive headers like Authorization, Cookie, etc. +JsonSockLogHeaders X-Request-Id X-Trace-Id User-Agent Referer X-Forwarded-For + +# Maximum number of headers to log (from the configured list) +JsonSockLogMaxHeaders 10 + +# Maximum length of each header value (longer values are truncated) +JsonSockLogMaxHeaderValueLen 256 + +# Minimum delay between reconnect attempts to the Unix socket (seconds) +JsonSockLogReconnectInterval 10 + +# Minimum delay between error messages to Apache error_log (seconds) +JsonSockLogErrorReportInterval 10 diff --git a/packaging/deb/compat b/packaging/deb/compat new file mode 100644 index 0000000..f599e28 --- /dev/null +++ b/packaging/deb/compat @@ -0,0 +1 @@ +10 diff --git a/packaging/deb/control b/packaging/deb/control new file mode 100644 index 0000000..aea3000 --- /dev/null +++ b/packaging/deb/control @@ -0,0 +1,27 @@ +Source: mod-reqin-log +Section: web +Priority: optional +Maintainer: Developer +Build-Depends: debhelper (>= 10), + apache2-dev, + apache2, + build-essential, + pkg-config +Standards-Version: 4.5.0 +Homepage: https://github.com/example/mod_reqin_log + +Package: libapache2-mod-reqin-log +Architecture: any +Depends: apache2, ${shlibs:Depends}, ${misc:Depends} +Description: Apache HTTPD module for logging HTTP requests as JSON to Unix socket + mod_reqin_log is an Apache HTTPD 2.4 module that logs all incoming HTTP requests + as JSON lines to a Unix domain socket. The logging occurs at request reception + time (post_read_request phase), capturing input-side data without waiting for + application processing. + . + Features: + - Non-blocking I/O to avoid stalling worker processes + - Configurable header logging with truncation support + - Automatic reconnection to Unix socket on failure + - Throttled error reporting to Apache error_log + - Compatible with prefork, worker, and event MPMs diff --git a/packaging/deb/install b/packaging/deb/install new file mode 100644 index 0000000..d798a22 --- /dev/null +++ b/packaging/deb/install @@ -0,0 +1,2 @@ +usr/lib/apache2/modules/mod_reqin_log.so +etc/apache2/conf-available/mod_reqin_log.conf diff --git a/packaging/deb/rules b/packaging/deb/rules new file mode 100644 index 0000000..efb309e --- /dev/null +++ b/packaging/deb/rules @@ -0,0 +1,24 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_build: + $(MAKE) APXS=/usr/bin/apxs + +override_dh_auto_install: + $(MAKE) install DESTDIR=$(CURDIR)/debian/libapache2-mod-reqin-log APXS=/usr/bin/apxs + install -d $(CURDIR)/debian/libapache2-mod-reqin-log/etc/apache2/conf-available/ + install -m 644 conf/mod_reqin_log.conf $(CURDIR)/debian/libapache2-mod-reqin-log/etc/apache2/conf-available/mod_reqin_log.conf + +override_dh_auto_clean: + $(MAKE) clean || true + dh_auto_clean + +override_dh_strip_nondeterminism: + # Nothing to strip + +override_dh_install: + dh_install + install -d $(CURDIR)/debian/libapache2-mod-reqin-log/usr/lib/apache2/modules/ + install -m 755 .libs/mod_reqin_log.so $(CURDIR)/debian/libapache2-mod-reqin-log/usr/lib/apache2/modules/ diff --git a/packaging/rpm/mod_reqin_log.spec b/packaging/rpm/mod_reqin_log.spec new file mode 100644 index 0000000..0ce0c5a --- /dev/null +++ b/packaging/rpm/mod_reqin_log.spec @@ -0,0 +1,53 @@ +Name: mod_reqin_log +Version: 1.0.0 +Release: 1%{?dist} +Summary: Apache HTTPD module for logging HTTP requests as JSON to Unix socket + +License: Apache-2.0 +URL: https://github.com/example/mod_reqin_log +Source0: %{name}-%{version}.tar.gz + +BuildRequires: gcc +BuildRequires: make +BuildRequires: httpd +BuildRequires: httpd-devel +BuildRequires: apr-devel +BuildRequires: apr-util-devel + +Requires: httpd + +%description +mod_reqin_log is an Apache HTTPD 2.4 module that logs all incoming HTTP requests +as JSON lines to a Unix domain socket. The logging occurs at request reception +time (post_read_request phase), capturing input-side data without waiting for +application processing. + +Features: +- Non-blocking I/O to avoid stalling worker processes +- Configurable header logging with truncation support +- Automatic reconnection to Unix socket on failure +- Throttled error reporting to Apache error_log +- Compatible with prefork, worker, and event MPMs + +%prep +%setup -q + +%build +%{__make} %{?_smp_mflags} APXS=%{_bindir}/apxs + +%install +%{__make} install DESTDIR=%{buildroot} APXS=%{_bindir}/apxs + +# Install configuration file +mkdir -p %{buildroot}%{_sysconfdir}/httpd/conf.d +install -m 644 conf/mod_reqin_log.conf %{buildroot}%{_sysconfdir}/httpd/conf.d/ + +%files +%{_libdir}/httpd/modules/mod_reqin_log.so +%config(noreplace) %{_sysconfdir}/httpd/conf.d/mod_reqin_log.conf +%doc README.md +%license LICENSE + +%changelog +* Thu Feb 26 2026 Developer - 1.0.0-1 +- Initial package release diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..e236042 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# +# build.sh - Build mod_reqin_log in Docker +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IMAGE_NAME="mod_reqin_log-build" + +echo "Building Docker image..." +docker build -t "$IMAGE_NAME" "$SCRIPT_DIR/.." + +echo "" +echo "Build complete. Extracting module..." + +# Create dist directory +mkdir -p "$SCRIPT_DIR/../dist" + +# Extract the built module from container +docker run --rm -v "$SCRIPT_DIR/../dist:/output" "$IMAGE_NAME" cp /build/modules/mod_reqin_log.so /output/ + +echo "" +echo "Module built successfully: $SCRIPT_DIR/../dist/mod_reqin_log.so" +echo "" +echo "To test the module:" +echo " docker run --rm -v \$PWD/dist:/modules mod_reqin_log-build httpd -t -C 'LoadModule reqin_log_module /modules/mod_reqin_log.so'" diff --git a/scripts/run_integration_tests.sh b/scripts/run_integration_tests.sh new file mode 100755 index 0000000..637b586 --- /dev/null +++ b/scripts/run_integration_tests.sh @@ -0,0 +1,267 @@ +#!/bin/bash +# +# run_integration_tests.sh - Integration test script for mod_reqin_log +# +# This script runs integration tests for the mod_reqin_log module. +# It requires: +# - Apache HTTPD with the module loaded +# - A running socket consumer +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOCKET_PATH="/tmp/mod_reqin_log.sock" +LOG_FILE="/tmp/mod_reqin_log_test.log" +APACHE_URL="${APACHE_URL:-http://localhost}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +log_info() { + echo -e "${YELLOW}[INFO]${NC} $1" +} + +log_pass() { + echo -e "${GREEN}[PASS]${NC} $1" + ((TESTS_PASSED++)) +} + +log_fail() { + echo -e "${RED}[FAIL]${NC} $1" + ((TESTS_FAILED++)) +} + +cleanup() { + log_info "Cleaning up..." + rm -f "$LOG_FILE" + rm -f "$SOCKET_PATH" +} + +trap cleanup EXIT + +# Check prerequisites +check_prerequisites() { + log_info "Checking prerequisites..." + + if ! command -v curl &> /dev/null; then + echo "Error: curl is required but not installed." + exit 1 + fi + + if ! command -v python3 &> /dev/null; then + echo "Error: python3 is required but not installed." + exit 1 + fi +} + +# Start socket consumer +start_consumer() { + log_info "Starting socket consumer..." + python3 "$SCRIPT_DIR/socket_consumer.py" "$SOCKET_PATH" -o "$LOG_FILE" & + CONSUMER_PID=$! + sleep 2 + + if ! kill -0 $CONSUMER_PID 2>/dev/null; then + echo "Error: Failed to start socket consumer" + exit 1 + fi +} + +# Stop socket consumer +stop_consumer() { + log_info "Stopping socket consumer..." + if [ -n "$CONSUMER_PID" ]; then + kill $CONSUMER_PID 2>/dev/null || true + wait $CONSUMER_PID 2>/dev/null || true + fi +} + +# Test: Basic request logging +test_basic_logging() { + ((TESTS_RUN++)) + log_info "Test: Basic request logging" + + curl -s "$APACHE_URL/" > /dev/null + sleep 1 + + if grep -q '"method":"GET"' "$LOG_FILE" 2>/dev/null; then + log_pass "Basic logging test" + else + log_fail "Basic logging test - No GET method found in logs" + fi +} + +# Test: Custom header logging +test_custom_headers() { + ((TESTS_RUN++)) + log_info "Test: Custom header logging" + + curl -s -H "X-Request-Id: test-12345" "$APACHE_URL/" > /dev/null + sleep 1 + + if grep -q '"header_X-Request-Id":"test-12345"' "$LOG_FILE" 2>/dev/null; then + log_pass "Custom header logging test" + else + log_fail "Custom header logging test - X-Request-Id not found in logs" + fi +} + +# Test: Multiple headers +test_multiple_headers() { + ((TESTS_RUN++)) + log_info "Test: Multiple headers" + + curl -s \ + -H "X-Request-Id: req-abc" \ + -H "X-Trace-Id: trace-xyz" \ + "$APACHE_URL/" > /dev/null + sleep 1 + + local found_request_id=$(grep -c '"header_X-Request-Id":"req-abc"' "$LOG_FILE" 2>/dev/null || echo 0) + local found_trace_id=$(grep -c '"header_X-Trace-Id":"trace-xyz"' "$LOG_FILE" 2>/dev/null || echo 0) + + if [ "$found_request_id" -gt 0 ] && [ "$found_trace_id" -gt 0 ]; then + log_pass "Multiple headers test" + else + log_fail "Multiple headers test - Not all headers found" + fi +} + +# Test: JSON format validation +test_json_format() { + ((TESTS_RUN++)) + log_info "Test: JSON format validation" + + curl -s "$APACHE_URL/" > /dev/null + sleep 1 + + # Get last line and validate JSON + local last_line=$(tail -1 "$LOG_FILE" 2>/dev/null | sed 's/^\[.*\] //') + + if echo "$last_line" | python3 -m json.tool > /dev/null 2>&1; then + log_pass "JSON format validation test" + else + log_fail "JSON format validation test - Invalid JSON format" + fi +} + +# Test: Required fields presence +test_required_fields() { + ((TESTS_RUN++)) + log_info "Test: Required fields presence" + + curl -s "$APACHE_URL/" > /dev/null + sleep 1 + + local last_line=$(tail -1 "$LOG_FILE" 2>/dev/null | sed 's/^\[.*\] //') + + local required_fields=("time" "timestamp" "src_ip" "dst_ip" "method" "path" "host") + local all_present=true + + for field in "${required_fields[@]}"; do + if ! echo "$last_line" | grep -q "\"$field\":"; then + all_present=false + break + fi + done + + if $all_present; then + log_pass "Required fields presence test" + else + log_fail "Required fields presence test - Missing required fields" + fi +} + +# Test: HTTP method variations +test_method_variations() { + ((TESTS_RUN++)) + log_info "Test: HTTP method variations" + + curl -s -X POST "$APACHE_URL/" > /dev/null + curl -s -X PUT "$APACHE_URL/" > /dev/null + curl -s -X DELETE "$APACHE_URL/" > /dev/null + sleep 1 + + local found_post=$(grep -c '"method":"POST"' "$LOG_FILE" 2>/dev/null || echo 0) + local found_put=$(grep -c '"method":"PUT"' "$LOG_FILE" 2>/dev/null || echo 0) + local found_delete=$(grep -c '"method":"DELETE"' "$LOG_FILE" 2>/dev/null || echo 0) + + if [ "$found_post" -gt 0 ] && [ "$found_put" -gt 0 ] && [ "$found_delete" -gt 0 ]; then + log_pass "HTTP method variations test" + else + log_fail "HTTP method variations test - Not all methods found" + fi +} + +# Test: Path logging +test_path_logging() { + ((TESTS_RUN++)) + log_info "Test: Path logging" + + curl -s "$APACHE_URL/api/users" > /dev/null + curl -s "$APACHE_URL/foo/bar/baz" > /dev/null + sleep 1 + + local found_api=$(grep -c '"path":"/api/users"' "$LOG_FILE" 2>/dev/null || echo 0) + local found_foo=$(grep -c '"path":"/foo/bar/baz"' "$LOG_FILE" 2>/dev/null || echo 0) + + if [ "$found_api" -gt 0 ] && [ "$found_foo" -gt 0 ]; then + log_pass "Path logging test" + else + log_fail "Path logging test - Not all paths found" + fi +} + +# Main test runner +main() { + echo "========================================" + echo "mod_reqin_log Integration Tests" + echo "========================================" + echo "" + + check_prerequisites + start_consumer + + # Give Apache time to connect to socket + sleep 2 + + # Run tests + test_basic_logging + test_custom_headers + test_multiple_headers + test_json_format + test_required_fields + test_method_variations + test_path_logging + + # Stop consumer + stop_consumer + + # Summary + echo "" + echo "========================================" + echo "Test Summary" + echo "========================================" + echo "Tests run: $TESTS_RUN" + echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}" + echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}" + echo "" + + if [ $TESTS_FAILED -gt 0 ]; then + exit 1 + fi + + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +} + +main "$@" diff --git a/scripts/socket_consumer.py b/scripts/socket_consumer.py new file mode 100755 index 0000000..c9286ed --- /dev/null +++ b/scripts/socket_consumer.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +socket_consumer.py - Unix socket consumer for mod_reqin_log + +This script creates a Unix domain socket server that receives JSON log lines +from the mod_reqin_log Apache module. It is primarily used for testing and +development purposes. + +Usage: + python3 socket_consumer.py [socket_path] + +Example: + python3 socket_consumer.py /var/run/mod_reqin_log.sock +""" + +import socket +import os +import sys +import json +import signal +import argparse +from datetime import datetime + +# Default socket path +DEFAULT_SOCKET_PATH = "/tmp/mod_reqin_log.sock" + +# Global flag for graceful shutdown +shutdown_requested = False + + +def signal_handler(signum, frame): + """Handle shutdown signals gracefully.""" + global shutdown_requested + shutdown_requested = True + print("\nShutdown requested, finishing current operations...") + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Unix socket consumer for mod_reqin_log" + ) + parser.add_argument( + "socket_path", + nargs="?", + default=DEFAULT_SOCKET_PATH, + help=f"Path to Unix socket (default: {DEFAULT_SOCKET_PATH})" + ) + parser.add_argument( + "-q", "--quiet", + action="store_true", + help="Suppress log output" + ) + parser.add_argument( + "-o", "--output", + type=str, + help="Write logs to file instead of stdout" + ) + parser.add_argument( + "--validate-json", + action="store_true", + help="Validate JSON and pretty-print" + ) + return parser.parse_args() + + +def create_socket(socket_path): + """Create and bind Unix domain socket.""" + # Remove existing socket file + if os.path.exists(socket_path): + os.remove(socket_path) + + # Create socket + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(socket_path) + server.listen(5) + + # Set permissions (allow Apache to connect) + os.chmod(socket_path, 0o666) + + return server + + +def process_log_line(line, validate_json=False, output_file=None): + """Process a single log line.""" + line = line.strip() + if not line: + return + + if validate_json: + try: + log_entry = json.loads(line) + line = json.dumps(log_entry, indent=2) + except json.JSONDecodeError as e: + line = f"[INVALID JSON] {line}\nError: {e}" + + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + output = f"[{timestamp}] {line}" + + if output_file: + output_file.write(output + "\n") + output_file.flush() + else: + print(output) + + +def handle_client(conn, validate_json=False, output_file=None): + """Handle a client connection.""" + data = b"" + try: + while not shutdown_requested: + chunk = conn.recv(4096) + if not chunk: + break + + data += chunk + + # Process complete lines + while b"\n" in data: + newline_pos = data.index(b"\n") + line = data[:newline_pos].decode("utf-8", errors="replace") + data = data[newline_pos + 1:] + process_log_line(line, validate_json, output_file) + except Exception as e: + print(f"Error handling client: {e}", file=sys.stderr) + finally: + # Process any remaining data + if data: + line = data.decode("utf-8", errors="replace") + process_log_line(line, validate_json, output_file) + conn.close() + + +def main(): + """Main entry point.""" + args = parse_args() + + # Setup signal handlers + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + output_file = None + if args.output: + output_file = open(args.output, "a") + + try: + # Create socket + server = create_socket(args.socket_path) + print(f"Listening on {args.socket_path}", file=sys.stderr) + if not args.quiet: + print(f"Waiting for connections... (Ctrl+C to stop)", file=sys.stderr) + + # Accept connections + while not shutdown_requested: + try: + server.settimeout(1.0) + try: + conn, addr = server.accept() + except socket.timeout: + continue + + # Handle client in same thread for simplicity + handle_client(conn, args.validate_json, output_file) + except Exception as e: + if not shutdown_requested: + print(f"Accept error: {e}", file=sys.stderr) + + except Exception as e: + print(f"Fatal error: {e}", file=sys.stderr) + return 1 + + finally: + # Cleanup + if os.path.exists(args.socket_path): + os.remove(args.socket_path) + if output_file: + output_file.close() + print("Socket consumer stopped.", file=sys.stderr) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/socket_listener.py b/scripts/socket_listener.py new file mode 100644 index 0000000..3817efd --- /dev/null +++ b/scripts/socket_listener.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +socket_listener.py - Simple Unix socket listener for testing mod_reqin_log +Receives JSON log lines and writes them to a file. +""" + +import socket +import os +import sys +import signal +import argparse + +shutdown_requested = False + +def signal_handler(signum, frame): + global shutdown_requested + shutdown_requested = True + +def main(): + parser = argparse.ArgumentParser(description='Unix socket listener for testing') + parser.add_argument('socket_path', help='Path to Unix socket') + parser.add_argument('-o', '--output', required=True, help='Output file for logs') + args = parser.parse_args() + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Remove existing socket + if os.path.exists(args.socket_path): + os.remove(args.socket_path) + + # Create socket + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(args.socket_path) + server.listen(5) + os.chmod(args.socket_path, 0o666) + + print(f"Listening on {args.socket_path}", file=sys.stderr) + sys.stderr.flush() + + with open(args.output, 'w') as f: + while not shutdown_requested: + try: + server.settimeout(1.0) + try: + conn, addr = server.accept() + print(f"Connection accepted from {addr}", file=sys.stderr) + sys.stderr.flush() + except socket.timeout: + continue + + data = b"" + while not shutdown_requested: + chunk = conn.recv(4096) + if not chunk: + break + data += chunk + while b"\n" in data: + newline_pos = data.index(b"\n") + line = data[:newline_pos].decode("utf-8", errors="replace") + data = data[newline_pos + 1:] + if line.strip(): + f.write(line + "\n") + f.flush() + print(f"Received: {line[:100]}...", file=sys.stderr) + conn.close() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + + if os.path.exists(args.socket_path): + os.remove(args.socket_path) + print("Listener stopped.", file=sys.stderr) + +if __name__ == "__main__": + main() diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..f04f49c --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# +# test.sh - Run tests for mod_reqin_log in Docker +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IMAGE_NAME="mod_reqin_log-build" + +echo "========================================" +echo "mod_reqin_log - Test Suite" +echo "========================================" +echo "" + +# Build image if not exists +if ! docker images "$IMAGE_NAME" | grep -q "$IMAGE_NAME"; then + echo "Building Docker image first..." + "$SCRIPT_DIR/scripts/build.sh" +fi + +echo "Running unit tests..." +echo "" + +# Run unit tests in container +docker run --rm "$IMAGE_NAME" bash -c "cd build/tests && ctest --output-on-failure" + +echo "" +echo "========================================" +echo "Unit tests completed" +echo "========================================" diff --git a/scripts/test_unix_socket.sh b/scripts/test_unix_socket.sh new file mode 100755 index 0000000..1cbce61 --- /dev/null +++ b/scripts/test_unix_socket.sh @@ -0,0 +1,317 @@ +#!/bin/bash +# +# test_unix_socket.sh - Integration test for mod_reqin_log Unix socket logging +# +# This test verifies that: +# 1. The module connects to a Unix socket +# 2. HTTP requests generate JSON log entries +# 3. Log entries are properly formatted and sent to the socket +# + +set -e + +SOCKET_PATH="/tmp/mod_reqin_log_test.sock" +LOG_OUTPUT="/tmp/mod_reqin_log_output.jsonl" +APACHE_PORT="${APACHE_PORT:-8080}" +TIMEOUT=30 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { + echo -e "${YELLOW}[INFO]${NC} $1" +} + +log_pass() { + echo -e "${GREEN}[PASS]${NC} $1" +} + +log_fail() { + echo -e "${RED}[FAIL]${NC} $1" +} + +cleanup() { + log_info "Cleaning up..." + rm -f "$SOCKET_PATH" "$LOG_OUTPUT" + pkill -f "socket_listener.py" 2>/dev/null || true + pkill -f "apache.*test" 2>/dev/null || true +} + +trap cleanup EXIT + +# Check dependencies +check_dependencies() { + log_info "Checking dependencies..." + if ! command -v curl &> /dev/null; then + log_fail "curl is required but not installed" + exit 1 + fi + if ! command -v python3 &> /dev/null; then + log_fail "python3 is required but not installed" + exit 1 + fi + log_pass "Dependencies OK" +} + +# Start Unix socket listener that logs received data +start_socket_listener() { + log_info "Starting Unix socket listener on $SOCKET_PATH..." + rm -f "$SOCKET_PATH" + + # Use Python script to listen on Unix socket + python3 /build/scripts/socket_listener.py "$SOCKET_PATH" -o "$LOG_OUTPUT" & + LISTENER_PID=$! + + sleep 2 + + if ! kill -0 $LISTENER_PID 2>/dev/null; then + log_fail "Failed to start socket listener" + exit 1 + fi + + log_pass "Socket listener started (PID: $LISTENER_PID)" +} + +# Create Apache test configuration +create_apache_config() { + log_info "Creating Apache test configuration..." + + cat > /tmp/httpd_test.conf << EOF +ServerRoot "/etc/httpd" +Listen $APACHE_PORT +ServerName localhost + +LoadModule reqin_log_module /build/.libs/mod_reqin_log.so +LoadModule mpm_event_module modules/mod_mpm_event.so +LoadModule authz_core_module modules/mod_authz_core.so +LoadModule dir_module modules/mod_dir.so +LoadModule mime_module modules/mod_mime.so +LoadModule unixd_module modules/mod_unixd.so +LoadModule log_config_module modules/mod_log_config.so + +User apache +Group apache + +TypesConfig /etc/mime.types +DirectoryIndex index.html + +JsonSockLogEnabled On +JsonSockLogSocket "$SOCKET_PATH" +JsonSockLogHeaders X-Request-Id User-Agent X-Test-Header +JsonSockLogMaxHeaders 10 +JsonSockLogMaxHeaderValueLen 256 +JsonSockLogReconnectInterval 5 +JsonSockLogErrorReportInterval 5 + + + DocumentRoot /var/www/html + + Require all granted + + + +ErrorLog /dev/stderr +LogLevel warn +EOF + + log_pass "Apache configuration created" +} + +# Start Apache with test configuration +start_apache() { + log_info "Starting Apache with mod_reqin_log..." + + # Create document root if needed + mkdir -p /var/www/html + echo "

Test

" > /var/www/html/index.html + + # Check socket exists + if [ ! -S "$SOCKET_PATH" ]; then + log_fail "Socket file does not exist: $SOCKET_PATH" + ls -la /tmp/*.sock 2>&1 || true + exit 1 + fi + log_info "Socket file exists: $SOCKET_PATH" + ls -la "$SOCKET_PATH" + + # Start Apache and capture stderr + httpd -f /tmp/httpd_test.conf -DFOREGROUND 2>&1 & + APACHE_PID=$! + + # Wait for Apache to start + sleep 2 + + if ! kill -0 $APACHE_PID 2>/dev/null; then + log_fail "Failed to start Apache" + exit 1 + fi + + log_pass "Apache started (PID: $APACHE_PID)" + + # Wait for Apache workers to initialize and connect to socket + log_info "Waiting for Apache workers to initialize..." + sleep 3 +} + +# Send test HTTP requests +send_test_requests() { + log_info "Sending test HTTP requests..." + + # Health check first + local retries=5 + while [ $retries -gt 0 ]; do + if curl -s -o /dev/null -w "%{http_code}" "http://localhost:$APACHE_PORT/" | grep -q "200"; then + break + fi + sleep 1 + retries=$((retries - 1)) + done + + if [ $retries -eq 0 ]; then + log_fail "Apache health check failed" + return 1 + fi + log_info "Apache health check passed" + + # Request 1: Basic GET + curl -s "http://localhost:$APACHE_PORT/" > /dev/null + sleep 0.5 + + # Request 2: With custom headers + curl -s -H "X-Request-Id: test-12345" "http://localhost:$APACHE_PORT/" > /dev/null + sleep 0.5 + + # Request 3: POST request + curl -s -X POST -d "test=data" "http://localhost:$APACHE_PORT/" > /dev/null + sleep 0.5 + + # Request 4: With User-Agent + curl -s -A "TestAgent/1.0" "http://localhost:$APACHE_PORT/api/test" > /dev/null + sleep 0.5 + + # Request 5: Multiple headers + curl -s \ + -H "X-Request-Id: req-abc" \ + -H "X-Test-Header: header-value" \ + -H "User-Agent: Mozilla/5.0" \ + "http://localhost:$APACHE_PORT/page" > /dev/null + sleep 1 + + log_pass "Test requests sent" +} + +# Verify log output +verify_logs() { + log_info "Verifying log output..." + + if [ ! -f "$LOG_OUTPUT" ]; then + log_fail "Log file not created" + return 1 + fi + + local log_count=$(wc -l < "$LOG_OUTPUT") + if [ "$log_count" -lt 1 ]; then + log_fail "Expected at least 1 log entry, got $log_count" + return 1 + fi + + log_pass "Received $log_count log entries" + + # Show all log entries + log_info "Log file contents:" + cat "$LOG_OUTPUT" + + # Verify JSON format + log_info "Validating JSON format..." + local invalid_json=0 + while IFS= read -r line; do + if [ -n "$line" ]; then + if ! echo "$line" | python3 -m json.tool > /dev/null 2>&1; then + log_fail "Invalid JSON: $line" + invalid_json=1 + fi + fi + done < "$LOG_OUTPUT" + + if [ $invalid_json -eq 0 ]; then + log_pass "All JSON entries are valid" + fi + + # Verify required fields + log_info "Checking required fields..." + local first_line=$(head -1 "$LOG_OUTPUT") + + local required_fields=("time" "timestamp" "src_ip" "dst_ip" "method" "path" "host") + local missing_fields=0 + + for field in "${required_fields[@]}"; do + if ! echo "$first_line" | grep -q "\"$field\":"; then + log_fail "Missing required field: $field" + missing_fields=1 + fi + done + + if [ $missing_fields -eq 0 ]; then + log_pass "All required fields present" + fi + + # Verify header logging + log_info "Checking header logging..." + if grep -q '"header_X-Request-Id"' "$LOG_OUTPUT"; then + log_pass "Custom headers are logged" + else + log_info "Custom headers test skipped (no X-Request-Id in requests)" + fi + + # Verify HTTP methods + log_info "Checking HTTP methods..." + if grep -q '"method":"GET"' "$LOG_OUTPUT"; then + log_pass "GET method logged" + fi + + if grep -q '"method":"POST"' "$LOG_OUTPUT"; then + log_pass "POST method logged" + fi + + # Show sample log entry + log_info "Sample log entry (formatted):" + head -1 "$LOG_OUTPUT" | python3 -m json.tool 2>/dev/null || head -1 "$LOG_OUTPUT" + + return 0 +} + +# Main test runner +main() { + echo "========================================" + echo "mod_reqin_log Unix Socket Integration Test" + echo "========================================" + echo "" + + check_dependencies + start_socket_listener + create_apache_config + start_apache + send_test_requests + + log_info "Waiting for logs to be written..." + sleep 2 + + verify_logs + local result=$? + + echo "" + echo "========================================" + if [ $result -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + else + echo -e "${RED}Some tests failed!${NC}" + fi + echo "========================================" + + return $result +} + +main "$@" diff --git a/src/mod_reqin_log.c b/src/mod_reqin_log.c new file mode 100644 index 0000000..f5494f3 --- /dev/null +++ b/src/mod_reqin_log.c @@ -0,0 +1,614 @@ +/* + * mod_reqin_log.c - Apache HTTPD module for logging HTTP requests as JSON to Unix socket + * + * Copyright (c) 2026. All rights reserved. + */ + +#include "httpd.h" +#include "http_config.h" +#include "http_core.h" +#include "http_log.h" +#include "http_protocol.h" +#include "http_request.h" +#include "apr_strings.h" +#include "apr_time.h" +#include "apr_lib.h" +#include "ap_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MOD_REQIN_LOG_NAME "mod_reqin_log" + +/* Default configuration values */ +#define DEFAULT_MAX_HEADERS 10 +#define DEFAULT_MAX_HEADER_VALUE_LEN 256 +#define DEFAULT_RECONNECT_INTERVAL 10 +#define DEFAULT_ERROR_REPORT_INTERVAL 10 + +/* Module configuration structure */ +typedef struct { + int enabled; + const char *socket_path; + apr_array_header_t *headers; + int max_headers; + int max_header_value_len; + int reconnect_interval; + int error_report_interval; +} reqin_log_config_t; + +/* Dynamic string buffer */ +typedef struct { + char *data; + apr_size_t len; + apr_size_t capacity; + apr_pool_t *pool; +} dynbuf_t; + +/* Per-child process state */ +typedef struct { + int socket_fd; + apr_time_t last_connect_attempt; + apr_time_t last_error_report; + int connect_failed; +} reqin_log_child_state_t; + +/* Global child state (one per process) */ +static reqin_log_child_state_t g_child_state = { + .socket_fd = -1, + .last_connect_attempt = 0, + .last_error_report = 0, + .connect_failed = 0 +}; + +/* Forward declarations for helper functions */ +static void dynbuf_append(dynbuf_t *db, const char *str, apr_size_t len); +static void append_json_string(dynbuf_t *db, const char *str); +static void format_iso8601(dynbuf_t *db, apr_time_t t); + +/* Forward declarations for commands */ +static const char *cmd_set_enabled(cmd_parms *cmd, void *cfg, int flag); +static const char *cmd_set_socket(cmd_parms *cmd, void *cfg, const char *arg); +static const char *cmd_set_headers(cmd_parms *cmd, void *cfg, const char *arg); +static const char *cmd_set_max_headers(cmd_parms *cmd, void *cfg, const char *arg); +static const char *cmd_set_max_header_value_len(cmd_parms *cmd, void *cfg, const char *arg); +static const char *cmd_set_reconnect_interval(cmd_parms *cmd, void *cfg, const char *arg); +static const char *cmd_set_error_report_interval(cmd_parms *cmd, void *cfg, const char *arg); + +/* Forward declarations for hooks */ +static int reqin_log_post_read_request(request_rec *r); +static void reqin_log_child_init(apr_pool_t *p, server_rec *s); +static void reqin_log_register_hooks(apr_pool_t *p); + +/* Command table */ +static const command_rec reqin_log_cmds[] = { + AP_INIT_FLAG("JsonSockLogEnabled", cmd_set_enabled, NULL, RSRC_CONF, + "Enable or disable mod_reqin_log (On|Off)"), + AP_INIT_TAKE1("JsonSockLogSocket", cmd_set_socket, NULL, RSRC_CONF, + "Unix domain socket path for JSON logging"), + AP_INIT_ITERATE("JsonSockLogHeaders", cmd_set_headers, NULL, RSRC_CONF, + "List of HTTP header names to log"), + AP_INIT_TAKE1("JsonSockLogMaxHeaders", cmd_set_max_headers, NULL, RSRC_CONF, + "Maximum number of headers to log (default: 10)"), + AP_INIT_TAKE1("JsonSockLogMaxHeaderValueLen", cmd_set_max_header_value_len, NULL, RSRC_CONF, + "Maximum length of header value to log (default: 256)"), + AP_INIT_TAKE1("JsonSockLogReconnectInterval", cmd_set_reconnect_interval, NULL, RSRC_CONF, + "Reconnect interval in seconds (default: 10)"), + AP_INIT_TAKE1("JsonSockLogErrorReportInterval", cmd_set_error_report_interval, NULL, RSRC_CONF, + "Error report interval in seconds (default: 10)"), + { NULL } +}; + +/* Module definition */ +module AP_MODULE_DECLARE_DATA reqin_log_module = { + STANDARD20_MODULE_STUFF, + NULL, /* per-directory config creator */ + NULL, /* dir config merger */ + NULL, /* server config creator */ + NULL, /* server config merger */ + reqin_log_cmds, /* command table */ + reqin_log_register_hooks /* register hooks */ +}; + +/* Get module configuration */ +static reqin_log_config_t *get_module_config(server_rec *s) +{ + reqin_log_config_t *cfg = (reqin_log_config_t *)ap_get_module_config(s->module_config, &reqin_log_module); + return cfg; +} + +/* ============== Dynamic Buffer Functions ============== */ + +static void dynbuf_init(dynbuf_t *db, apr_pool_t *pool, apr_size_t initial_capacity) +{ + db->pool = pool; + db->capacity = initial_capacity; + db->len = 0; + db->data = apr_palloc(pool, initial_capacity); + db->data[0] = '\0'; +} + +static void dynbuf_append(dynbuf_t *db, const char *str, apr_size_t len) +{ + if (str == NULL) return; + + if (len == (apr_size_t)-1) { + len = strlen(str); + } + + if (db->len + len >= db->capacity) { + apr_size_t new_capacity = (db->len + len + 1) * 2; + char *new_data = apr_palloc(db->pool, new_capacity); + memcpy(new_data, db->data, db->len); + db->data = new_data; + db->capacity = new_capacity; + } + + memcpy(db->data + db->len, str, len); + db->len += len; + db->data[db->len] = '\0'; +} + +static void dynbuf_append_char(dynbuf_t *db, char c) +{ + if (db->len + 1 >= db->capacity) { + apr_size_t new_capacity = db->capacity * 2; + char *new_data = apr_palloc(db->pool, new_capacity); + memcpy(new_data, db->data, db->len); + db->data = new_data; + db->capacity = new_capacity; + } + db->data[db->len++] = c; + db->data[db->len] = '\0'; +} + +/* ============== JSON Helper Functions ============== */ + +static void append_json_string(dynbuf_t *db, const char *str) +{ + if (str == NULL) { + return; + } + + for (const char *p = str; *p; p++) { + char c = *p; + switch (c) { + case '"': dynbuf_append(db, "\\\"", 2); break; + case '\\': dynbuf_append(db, "\\\\", 2); break; + case '\b': dynbuf_append(db, "\\b", 2); break; + case '\f': dynbuf_append(db, "\\f", 2); break; + case '\n': dynbuf_append(db, "\\n", 2); break; + case '\r': dynbuf_append(db, "\\r", 2); break; + case '\t': dynbuf_append(db, "\\t", 2); break; + default: + if ((unsigned char)c < 0x20) { + char unicode[8]; + snprintf(unicode, sizeof(unicode), "\\u%04x", (unsigned char)c); + dynbuf_append(db, unicode, -1); + } else { + dynbuf_append_char(db, c); + } + break; + } + } +} + +static void format_iso8601(dynbuf_t *db, apr_time_t t) +{ + apr_time_exp_t tm; + apr_time_exp_gmt(&tm, t); + + char time_str[32]; + snprintf(time_str, sizeof(time_str), "%04d-%02d-%02dT%02d:%02d:%02dZ", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); + dynbuf_append(db, time_str, -1); +} + +/* ============== Configuration Command Handlers ============== */ + +static const char *cmd_set_enabled(cmd_parms *cmd, void *cfg, int flag) +{ + reqin_log_config_t *conf = get_module_config(cmd->server); + if (conf == NULL) { + conf = apr_pcalloc(cmd->pool, sizeof(reqin_log_config_t)); + conf->headers = apr_array_make(cmd->pool, 0, sizeof(const char *)); + conf->max_headers = DEFAULT_MAX_HEADERS; + conf->max_header_value_len = DEFAULT_MAX_HEADER_VALUE_LEN; + conf->reconnect_interval = DEFAULT_RECONNECT_INTERVAL; + conf->error_report_interval = DEFAULT_ERROR_REPORT_INTERVAL; + ap_set_module_config(cmd->server->module_config, &reqin_log_module, conf); + } + conf->enabled = flag ? 1 : 0; + return NULL; +} + +static const char *cmd_set_socket(cmd_parms *cmd, void *cfg, const char *arg) +{ + reqin_log_config_t *conf = get_module_config(cmd->server); + if (conf == NULL) { + conf = apr_pcalloc(cmd->pool, sizeof(reqin_log_config_t)); + conf->enabled = 0; + conf->headers = apr_array_make(cmd->pool, 0, sizeof(const char *)); + conf->max_headers = DEFAULT_MAX_HEADERS; + conf->max_header_value_len = DEFAULT_MAX_HEADER_VALUE_LEN; + conf->reconnect_interval = DEFAULT_RECONNECT_INTERVAL; + conf->error_report_interval = DEFAULT_ERROR_REPORT_INTERVAL; + ap_set_module_config(cmd->server->module_config, &reqin_log_module, conf); + } + conf->socket_path = apr_pstrdup(cmd->pool, arg); + return NULL; +} + +static const char *cmd_set_headers(cmd_parms *cmd, void *cfg, const char *arg) +{ + reqin_log_config_t *conf = get_module_config(cmd->server); + if (conf == NULL) { + conf = apr_pcalloc(cmd->pool, sizeof(reqin_log_config_t)); + conf->enabled = 0; + conf->socket_path = NULL; + conf->max_headers = DEFAULT_MAX_HEADERS; + conf->max_header_value_len = DEFAULT_MAX_HEADER_VALUE_LEN; + conf->reconnect_interval = DEFAULT_RECONNECT_INTERVAL; + conf->error_report_interval = DEFAULT_ERROR_REPORT_INTERVAL; + ap_set_module_config(cmd->server->module_config, &reqin_log_module, conf); + } + if (conf->headers == NULL) { + conf->headers = apr_array_make(cmd->pool, 5, sizeof(const char *)); + } + *(const char **)apr_array_push(conf->headers) = apr_pstrdup(cmd->pool, arg); + return NULL; +} + +static const char *cmd_set_max_headers(cmd_parms *cmd, void *cfg, const char *arg) +{ + reqin_log_config_t *conf = get_module_config(cmd->server); + if (conf == NULL) { + conf = apr_pcalloc(cmd->pool, sizeof(reqin_log_config_t)); + conf->enabled = 0; + conf->socket_path = NULL; + conf->headers = apr_array_make(cmd->pool, 0, sizeof(const char *)); + conf->max_header_value_len = DEFAULT_MAX_HEADER_VALUE_LEN; + conf->reconnect_interval = DEFAULT_RECONNECT_INTERVAL; + conf->error_report_interval = DEFAULT_ERROR_REPORT_INTERVAL; + ap_set_module_config(cmd->server->module_config, &reqin_log_module, conf); + } + int val = atoi(arg); + if (val < 0) { + return "JsonSockLogMaxHeaders must be >= 0"; + } + conf->max_headers = val; + return NULL; +} + +static const char *cmd_set_max_header_value_len(cmd_parms *cmd, void *cfg, const char *arg) +{ + reqin_log_config_t *conf = get_module_config(cmd->server); + if (conf == NULL) { + conf = apr_pcalloc(cmd->pool, sizeof(reqin_log_config_t)); + conf->enabled = 0; + conf->socket_path = NULL; + conf->headers = apr_array_make(cmd->pool, 0, sizeof(const char *)); + conf->max_headers = DEFAULT_MAX_HEADERS; + conf->reconnect_interval = DEFAULT_RECONNECT_INTERVAL; + conf->error_report_interval = DEFAULT_ERROR_REPORT_INTERVAL; + ap_set_module_config(cmd->server->module_config, &reqin_log_module, conf); + } + int val = atoi(arg); + if (val < 1) { + return "JsonSockLogMaxHeaderValueLen must be >= 1"; + } + conf->max_header_value_len = val; + return NULL; +} + +static const char *cmd_set_reconnect_interval(cmd_parms *cmd, void *cfg, const char *arg) +{ + reqin_log_config_t *conf = get_module_config(cmd->server); + if (conf == NULL) { + conf = apr_pcalloc(cmd->pool, sizeof(reqin_log_config_t)); + conf->enabled = 0; + conf->socket_path = NULL; + conf->headers = apr_array_make(cmd->pool, 0, sizeof(const char *)); + conf->max_headers = DEFAULT_MAX_HEADERS; + conf->max_header_value_len = DEFAULT_MAX_HEADER_VALUE_LEN; + conf->error_report_interval = DEFAULT_ERROR_REPORT_INTERVAL; + ap_set_module_config(cmd->server->module_config, &reqin_log_module, conf); + } + int val = atoi(arg); + if (val < 0) { + return "JsonSockLogReconnectInterval must be >= 0"; + } + conf->reconnect_interval = val; + return NULL; +} + +static const char *cmd_set_error_report_interval(cmd_parms *cmd, void *cfg, const char *arg) +{ + reqin_log_config_t *conf = get_module_config(cmd->server); + if (conf == NULL) { + conf = apr_pcalloc(cmd->pool, sizeof(reqin_log_config_t)); + conf->enabled = 0; + conf->socket_path = NULL; + conf->headers = apr_array_make(cmd->pool, 0, sizeof(const char *)); + conf->max_headers = DEFAULT_MAX_HEADERS; + conf->max_header_value_len = DEFAULT_MAX_HEADER_VALUE_LEN; + conf->reconnect_interval = DEFAULT_RECONNECT_INTERVAL; + ap_set_module_config(cmd->server->module_config, &reqin_log_module, conf); + } + int val = atoi(arg); + if (val < 0) { + return "JsonSockLogErrorReportInterval must be >= 0"; + } + conf->error_report_interval = val; + return NULL; +} + +/* ============== Socket Functions ============== */ + +static int try_connect(reqin_log_config_t *cfg, server_rec *s) +{ + apr_time_t now = apr_time_now(); + apr_time_t interval = apr_time_from_sec(cfg->reconnect_interval); + + if (g_child_state.connect_failed && + (now - g_child_state.last_connect_attempt) < interval) { + return -1; + } + + g_child_state.last_connect_attempt = now; + + if (g_child_state.socket_fd < 0) { + g_child_state.socket_fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (g_child_state.socket_fd < 0) { + ap_log_error(APLOG_MARK, APLOG_ERR, errno, s, + MOD_REQIN_LOG_NAME ": Failed to create socket"); + return -1; + } + + int flags = fcntl(g_child_state.socket_fd, F_GETFL, 0); + fcntl(g_child_state.socket_fd, F_SETFL, flags | O_NONBLOCK); + } + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", cfg->socket_path); + + int rc = connect(g_child_state.socket_fd, (struct sockaddr *)&addr, sizeof(addr)); + if (rc < 0) { + int err = errno; + if (err != EINPROGRESS && err != EAGAIN && err != EWOULDBLOCK) { + close(g_child_state.socket_fd); + g_child_state.socket_fd = -1; + g_child_state.connect_failed = 1; + + if ((now - g_child_state.last_error_report) >= apr_time_from_sec(cfg->error_report_interval)) { + ap_log_error(APLOG_MARK, APLOG_ERR, err, s, + MOD_REQIN_LOG_NAME ": Unix socket connect failed: %s", cfg->socket_path); + g_child_state.last_error_report = now; + } + return -1; + } + } + + g_child_state.connect_failed = 0; + return 0; +} + +static int ensure_connected(reqin_log_config_t *cfg, server_rec *s) +{ + if (g_child_state.socket_fd >= 0 && !g_child_state.connect_failed) { + return 0; + } + return try_connect(cfg, s); +} + +static int write_to_socket(const char *data, apr_size_t len, server_rec *s, reqin_log_config_t *cfg) +{ + if (g_child_state.socket_fd < 0) { + return -1; + } + + apr_size_t total_written = 0; + while (total_written < len) { + ssize_t n = write(g_child_state.socket_fd, data + total_written, len - total_written); + if (n < 0) { + int err = errno; + if (err == EAGAIN || err == EWOULDBLOCK) { + return -1; + } + if (err == EPIPE || err == ECONNRESET) { + close(g_child_state.socket_fd); + g_child_state.socket_fd = -1; + g_child_state.connect_failed = 1; + + apr_time_t now = apr_time_now(); + if ((now - g_child_state.last_error_report) >= apr_time_from_sec(cfg->error_report_interval)) { + ap_log_error(APLOG_MARK, APLOG_ERR, err, s, + MOD_REQIN_LOG_NAME ": Unix socket write failed: %s", strerror(err)); + g_child_state.last_error_report = now; + } + return -1; + } + return -1; + } + total_written += n; + } + + return 0; +} + +/* ============== Request Logging Functions ============== */ + +static const char *get_header(request_rec *r, const char *name) +{ + const apr_table_t *headers = r->headers_in; + apr_table_entry_t *elts = (apr_table_entry_t *)apr_table_elts(headers)->elts; + int nelts = apr_table_elts(headers)->nelts; + + for (int i = 0; i < nelts; i++) { + if (strcasecmp(elts[i].key, name) == 0) { + return elts[i].val; + } + } + return NULL; +} + +static void log_request(request_rec *r, reqin_log_config_t *cfg) +{ + apr_pool_t *pool = r->pool; + server_rec *s = r->server; + char port_buf[16]; + + if (ensure_connected(cfg, s) < 0) { + return; + } + + dynbuf_t buf; + dynbuf_init(&buf, pool, 4096); + + dynbuf_append(&buf, "{", 1); + + /* time */ + dynbuf_append(&buf, "\"time\":\"", 8); + format_iso8601(&buf, r->request_time); + dynbuf_append(&buf, "\",", 2); + + /* timestamp */ + apr_time_t now = apr_time_now(); + apr_uint64_t ns = (apr_uint64_t)now * 1000; + char ts_buf[32]; + snprintf(ts_buf, sizeof(ts_buf), "%" APR_UINT64_T_FMT, ns); + dynbuf_append(&buf, "\"timestamp\":", 12); + dynbuf_append(&buf, ts_buf, -1); + dynbuf_append(&buf, ",", 1); + + /* src_ip */ + dynbuf_append(&buf, "\"src_ip\":\"", 10); + dynbuf_append(&buf, r->useragent_ip ? r->useragent_ip : r->connection->client_ip, -1); + dynbuf_append(&buf, "\",", 2); + + /* src_port */ + port_buf[0] = '\0'; + if (r->connection->client_addr != NULL) { + snprintf(port_buf, sizeof(port_buf), "%u", r->connection->client_addr->port); + } + dynbuf_append(&buf, "\"src_port\":", 11); + dynbuf_append(&buf, port_buf, -1); + dynbuf_append(&buf, ",", 1); + + /* dst_ip */ + dynbuf_append(&buf, "\"dst_ip\":\"", 10); + dynbuf_append(&buf, r->connection->local_ip, -1); + dynbuf_append(&buf, "\",", 2); + + /* dst_port */ + port_buf[0] = '\0'; + if (r->connection->local_addr != NULL) { + snprintf(port_buf, sizeof(port_buf), "%u", r->connection->local_addr->port); + } + dynbuf_append(&buf, "\"dst_port\":", 11); + dynbuf_append(&buf, port_buf, -1); + dynbuf_append(&buf, ",", 1); + + /* method */ + dynbuf_append(&buf, "\"method\":\"", 10); + append_json_string(&buf, r->method); + dynbuf_append(&buf, "\",", 2); + + /* path */ + dynbuf_append(&buf, "\"path\":\"", 8); + append_json_string(&buf, r->parsed_uri.path ? r->parsed_uri.path : "/"); + dynbuf_append(&buf, "\",", 2); + + /* host */ + const char *host = apr_table_get(r->headers_in, "Host"); + dynbuf_append(&buf, "\"host\":\"", 8); + append_json_string(&buf, host ? host : ""); + dynbuf_append(&buf, "\",", 2); + + /* http_version */ + dynbuf_append(&buf, "\"http_version\":\"", 16); + dynbuf_append(&buf, r->protocol, -1); + dynbuf_append(&buf, "\"", 1); + + /* headers - flat structure at same level as other fields */ + if (cfg->headers && cfg->headers->nelts > 0) { + int header_count = 0; + int max_to_log = cfg->max_headers; + const char **header_names = (const char **)cfg->headers->elts; + + for (int i = 0; i < cfg->headers->nelts && header_count < max_to_log; i++) { + const char *header_name = header_names[i]; + const char *header_value = get_header(r, header_name); + + if (header_value != NULL) { + dynbuf_append(&buf, ",\"header_", 9); + append_json_string(&buf, header_name); + dynbuf_append(&buf, "\":\"", 3); + + apr_size_t val_len = strlen(header_value); + if ((int)val_len > cfg->max_header_value_len) { + val_len = cfg->max_header_value_len; + } + + char *truncated = apr_pstrmemdup(pool, header_value, val_len); + append_json_string(&buf, truncated); + dynbuf_append(&buf, "\"", 1); + + header_count++; + } + } + } + + dynbuf_append(&buf, "}\n", 2); + + write_to_socket(buf.data, buf.len, s, cfg); +} + +/* ============== Apache Hooks ============== */ + +static int reqin_log_post_read_request(request_rec *r) +{ + reqin_log_config_t *cfg = get_module_config(r->server); + + if (cfg == NULL || !cfg->enabled || cfg->socket_path == NULL) { + return DECLINED; + } + + log_request(r, cfg); + return DECLINED; +} + +static void reqin_log_child_init(apr_pool_t *p, server_rec *s) +{ + (void)p; + + reqin_log_config_t *cfg = get_module_config(s); + + g_child_state.socket_fd = -1; + g_child_state.last_connect_attempt = 0; + g_child_state.last_error_report = 0; + g_child_state.connect_failed = 0; + + if (cfg == NULL || !cfg->enabled || cfg->socket_path == NULL) { + return; + } + + try_connect(cfg, s); +} + +static void reqin_log_register_hooks(apr_pool_t *p) +{ + (void)p; + ap_hook_post_read_request(reqin_log_post_read_request, NULL, NULL, APR_HOOK_MIDDLE); + ap_hook_child_init(reqin_log_child_init, NULL, NULL, APR_HOOK_MIDDLE); +} diff --git a/src/mod_reqin_log.h b/src/mod_reqin_log.h new file mode 100644 index 0000000..0205cd4 --- /dev/null +++ b/src/mod_reqin_log.h @@ -0,0 +1,36 @@ +/* + * mod_reqin_log.h - Apache HTTPD module for logging HTTP requests as JSON to Unix socket + * + * Copyright (c) 2026. All rights reserved. + */ + +#ifndef MOD_REQIN_LOG_H +#define MOD_REQIN_LOG_H + +#include "httpd.h" +#include "http_config.h" + +/* Module name */ +#define MOD_REQIN_LOG_NAME "mod_reqin_log" + +/* Default configuration values */ +#define DEFAULT_MAX_HEADERS 10 +#define DEFAULT_MAX_HEADER_VALUE_LEN 256 +#define DEFAULT_RECONNECT_INTERVAL 10 +#define DEFAULT_ERROR_REPORT_INTERVAL 10 + +/* Module configuration structure */ +typedef struct { + int enabled; + const char *socket_path; + apr_array_header_t *headers; + int max_headers; + int max_header_value_len; + int reconnect_interval; + int error_report_interval; +} reqin_log_config_t; + +/* External module declaration */ +extern module AP_MODULE_DECLARE_DATA reqin_log_module; + +#endif /* MOD_REQIN_LOG_H */ diff --git a/tests/unit/test_config_parsing.c b/tests/unit/test_config_parsing.c new file mode 100644 index 0000000..ef1a503 --- /dev/null +++ b/tests/unit/test_config_parsing.c @@ -0,0 +1,215 @@ +/* + * test_config_parsing.c - Unit tests for configuration parsing + */ + +#include +#include +#include +#include +#include +#include +#include + +/* Default configuration values */ +#define DEFAULT_MAX_HEADERS 10 +#define DEFAULT_MAX_HEADER_VALUE_LEN 256 +#define DEFAULT_RECONNECT_INTERVAL 10 +#define DEFAULT_ERROR_REPORT_INTERVAL 10 + +/* Mock configuration structure */ +typedef struct { + int enabled; + const char *socket_path; + int max_headers; + int max_header_value_len; + int reconnect_interval; + int error_report_interval; +} mock_config_t; + +/* Mock parsing functions */ +static int parse_enabled(const char *value) +{ + if (strcasecmp(value, "on") == 0 || strcmp(value, "1") == 0) { + return 1; + } + return 0; +} + +static const char *parse_socket_path(const char *value) +{ + if (value == NULL || strlen(value) == 0) { + return NULL; + } + return value; +} + +static int parse_max_headers(const char *value, int *result) +{ + char *endptr; + long val = strtol(value, &endptr, 10); + if (*endptr != '\0' || val < 0) { + return -1; + } + *result = (int)val; + return 0; +} + +static int parse_interval(const char *value, int *result) +{ + char *endptr; + long val = strtol(value, &endptr, 10); + if (*endptr != '\0' || val < 0) { + return -1; + } + *result = (int)val; + return 0; +} + +/* Test: Parse enabled On */ +static void test_parse_enabled_on(void **state) +{ + assert_int_equal(parse_enabled("On"), 1); + assert_int_equal(parse_enabled("on"), 1); + assert_int_equal(parse_enabled("ON"), 1); + assert_int_equal(parse_enabled("1"), 1); +} + +/* Test: Parse enabled Off */ +static void test_parse_enabled_off(void **state) +{ + assert_int_equal(parse_enabled("Off"), 0); + assert_int_equal(parse_enabled("off"), 0); + assert_int_equal(parse_enabled("OFF"), 0); + assert_int_equal(parse_enabled("0"), 0); +} + +/* Test: Parse socket path valid */ +static void test_parse_socket_path_valid(void **state) +{ + const char *result = parse_socket_path("/var/run/mod_reqin_log.sock"); + assert_string_equal(result, "/var/run/mod_reqin_log.sock"); +} + +/* Test: Parse socket path empty */ +static void test_parse_socket_path_empty(void **state) +{ + const char *result = parse_socket_path(""); + assert_null(result); +} + +/* Test: Parse socket path NULL */ +static void test_parse_socket_path_null(void **state) +{ + const char *result = parse_socket_path(NULL); + assert_null(result); +} + +/* Test: Parse max headers valid */ +static void test_parse_max_headers_valid(void **state) +{ + int result; + assert_int_equal(parse_max_headers("10", &result), 0); + assert_int_equal(result, 10); + + assert_int_equal(parse_max_headers("0", &result), 0); + assert_int_equal(result, 0); + + assert_int_equal(parse_max_headers("100", &result), 0); + assert_int_equal(result, 100); +} + +/* Test: Parse max headers invalid */ +static void test_parse_max_headers_invalid(void **state) +{ + int result; + assert_int_equal(parse_max_headers("-1", &result), -1); + assert_int_equal(parse_max_headers("abc", &result), -1); + assert_int_equal(parse_max_headers("10abc", &result), -1); +} + +/* Test: Parse reconnect interval valid */ +static void test_parse_reconnect_interval_valid(void **state) +{ + int result; + assert_int_equal(parse_interval("10", &result), 0); + assert_int_equal(result, 10); + + assert_int_equal(parse_interval("0", &result), 0); + assert_int_equal(result, 0); + + assert_int_equal(parse_interval("60", &result), 0); + assert_int_equal(result, 60); +} + +/* Test: Parse reconnect interval invalid */ +static void test_parse_reconnect_interval_invalid(void **state) +{ + int result; + assert_int_equal(parse_interval("-5", &result), -1); + assert_int_equal(parse_interval("abc", &result), -1); +} + +/* Test: Default configuration values */ +static void test_default_config_values(void **state) +{ + assert_int_equal(DEFAULT_MAX_HEADERS, 10); + assert_int_equal(DEFAULT_MAX_HEADER_VALUE_LEN, 256); + assert_int_equal(DEFAULT_RECONNECT_INTERVAL, 10); + assert_int_equal(DEFAULT_ERROR_REPORT_INTERVAL, 10); +} + +/* Test: Configuration validation - enabled requires socket */ +static void test_config_validation_enabled_requires_socket(void **state) +{ + /* Valid: enabled with socket */ + int enabled = 1; + const char *socket = "/var/run/socket"; + assert_true(enabled == 0 || socket != NULL); + + /* Invalid: enabled without socket */ + socket = NULL; + assert_false(enabled == 0 || socket != NULL); +} + +/* Test: Header value length validation */ +static void test_header_value_len_validation(void **state) +{ + int result; + assert_int_equal(parse_interval("1", &result), 0); + assert_true(result >= 1); + + assert_int_equal(parse_interval("0", &result), 0); + assert_false(result >= 1); +} + +/* Test: Large but valid values */ +static void test_large_valid_values(void **state) +{ + int result; + assert_int_equal(parse_max_headers("1000000", &result), 0); + assert_int_equal(result, 1000000); + + assert_int_equal(parse_interval("86400", &result), 0); + assert_int_equal(result, 86400); +} + +int main(void) +{ + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_parse_enabled_on), + cmocka_unit_test(test_parse_enabled_off), + cmocka_unit_test(test_parse_socket_path_valid), + cmocka_unit_test(test_parse_socket_path_empty), + cmocka_unit_test(test_parse_socket_path_null), + cmocka_unit_test(test_parse_max_headers_valid), + cmocka_unit_test(test_parse_max_headers_invalid), + cmocka_unit_test(test_parse_reconnect_interval_valid), + cmocka_unit_test(test_parse_reconnect_interval_invalid), + cmocka_unit_test(test_default_config_values), + cmocka_unit_test(test_config_validation_enabled_requires_socket), + cmocka_unit_test(test_header_value_len_validation), + cmocka_unit_test(test_large_valid_values), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/tests/unit/test_header_handling.c b/tests/unit/test_header_handling.c new file mode 100644 index 0000000..d575500 --- /dev/null +++ b/tests/unit/test_header_handling.c @@ -0,0 +1,211 @@ +/* + * test_header_handling.c - Unit tests for header handling (truncation and limits) + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +/* Mock header truncation function */ +static char *truncate_header_value(apr_pool_t *pool, const char *value, int max_len) +{ + if (value == NULL) { + return NULL; + } + + size_t len = strlen(value); + if ((int)len > max_len) { + return apr_pstrmemdup(pool, value, max_len); + } + return apr_pstrdup(pool, value); +} + +/* Mock header matching function */ +static int header_name_matches(const char *configured, const char *actual) +{ + return strcasecmp(configured, actual) == 0; +} + +/* Test: Header value within limit */ +static void test_header_truncation_within_limit(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + const char *value = "short value"; + char *result = truncate_header_value(pool, value, 256); + + assert_string_equal(result, "short value"); + + apr_pool_destroy(pool); +} + +/* Test: Header value exactly at limit */ +static void test_header_truncation_exact_limit(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + const char *value = "exactly10c"; + char *result = truncate_header_value(pool, value, 10); + + assert_string_equal(result, "exactly10c"); + + apr_pool_destroy(pool); +} + +/* Test: Header value exceeds limit */ +static void test_header_truncation_exceeds_limit(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + const char *value = "this is a very long header value that should be truncated"; + char *result = truncate_header_value(pool, value, 15); + + assert_string_equal(result, "this is a very "); + assert_int_equal(strlen(result), 15); + + apr_pool_destroy(pool); +} + +/* Test: Header value with limit of 1 */ +static void test_header_truncation_limit_one(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + const char *value = "abc"; + char *result = truncate_header_value(pool, value, 1); + + assert_string_equal(result, "a"); + + apr_pool_destroy(pool); +} + +/* Test: NULL header value */ +static void test_header_truncation_null(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + char *result = truncate_header_value(pool, NULL, 256); + + assert_null(result); + + apr_pool_destroy(pool); +} + +/* Test: Empty header value */ +static void test_header_truncation_empty(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + const char *value = ""; + char *result = truncate_header_value(pool, value, 256); + + assert_string_equal(result, ""); + + apr_pool_destroy(pool); +} + +/* Test: Header name matching (case-insensitive) */ +static void test_header_name_matching_case_insensitive(void **state) +{ + assert_true(header_name_matches("X-Request-Id", "x-request-id")); + assert_true(header_name_matches("user-agent", "User-Agent")); + assert_true(header_name_matches("HOST", "host")); +} + +/* Test: Header name matching (different headers) */ +static void test_header_name_matching_different(void **state) +{ + assert_false(header_name_matches("X-Request-Id", "X-Trace-Id")); + assert_false(header_name_matches("Host", "User-Agent")); +} + +/* Test: Multiple headers with limit */ +static void test_header_count_limit(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + /* Simulate configured headers */ + const char *configured[] = {"X-Request-Id", "X-Trace-Id", "User-Agent", "Referer"}; + int configured_count = 4; + int max_headers = 2; + + /* Simulate present headers */ + const char *present[] = {"X-Request-Id", "User-Agent", "Referer"}; + int present_count = 3; + + int logged_count = 0; + for (int i = 0; i < configured_count && logged_count < max_headers; i++) { + for (int j = 0; j < present_count; j++) { + if (header_name_matches(configured[i], present[j])) { + logged_count++; + break; + } + } + } + + assert_int_equal(logged_count, 2); + + apr_pool_destroy(pool); +} + +/* Test: Header value with special JSON characters */ +static void test_header_value_json_special(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + const char *value = "test\"value\\with\tspecial"; + char *truncated = truncate_header_value(pool, value, 256); + + /* Truncation should preserve the value */ + assert_string_equal(truncated, "test\"value\\with\tspecial"); + + apr_pool_destroy(pool); +} + +/* Test: Unicode in header value (UTF-8) */ +static void test_header_value_unicode(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + const char *value = "Mozilla/5.0 (compatible; 日本語)"; + char *result = truncate_header_value(pool, value, 50); + + /* Should be truncated but valid */ + assert_non_null(result); + assert_true(strlen(result) <= 50); + + apr_pool_destroy(pool); +} + +int main(void) +{ + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_header_truncation_within_limit), + cmocka_unit_test(test_header_truncation_exact_limit), + cmocka_unit_test(test_header_truncation_exceeds_limit), + cmocka_unit_test(test_header_truncation_limit_one), + cmocka_unit_test(test_header_truncation_null), + cmocka_unit_test(test_header_truncation_empty), + cmocka_unit_test(test_header_name_matching_case_insensitive), + cmocka_unit_test(test_header_name_matching_different), + cmocka_unit_test(test_header_count_limit), + cmocka_unit_test(test_header_value_json_special), + cmocka_unit_test(test_header_value_unicode), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +} diff --git a/tests/unit/test_json_serialization.c b/tests/unit/test_json_serialization.c new file mode 100644 index 0000000..5cefebe --- /dev/null +++ b/tests/unit/test_json_serialization.c @@ -0,0 +1,198 @@ +/* + * test_json_serialization.c - Unit tests for JSON serialization + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Mock JSON string escaping function for testing */ +static void append_json_string(apr_pool_t *pool, apr_strbuf_t *buf, const char *str) +{ + if (str == NULL) { + return; + } + + for (const char *p = str; *p; p++) { + char c = *p; + switch (c) { + case '"': apr_strbuf_append(buf, "\\\"", 2); break; + case '\\': apr_strbuf_append(buf, "\\\\", 2); break; + case '\b': apr_strbuf_append(buf, "\\b", 2); break; + case '\f': apr_strbuf_append(buf, "\\f", 2); break; + case '\n': apr_strbuf_append(buf, "\\n", 2); break; + case '\r': apr_strbuf_append(buf, "\\r", 2); break; + case '\t': apr_strbuf_append(buf, "\\t", 2); break; + default: + if ((unsigned char)c < 0x20) { + char unicode[8]; + apr_snprintf(unicode, sizeof(unicode), "\\u%04x", (unsigned char)c); + apr_strbuf_append(buf, unicode, -1); + } else { + apr_strbuf_append_char(buf, c); + } + break; + } + } +} + +/* Test: Empty string */ +static void test_json_escape_empty_string(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + apr_strbuf_t buf; + char *initial = apr_palloc(pool, 256); + apr_strbuf_init(pool, &buf, initial, 256); + + append_json_string(pool, &buf, ""); + + assert_string_equal(buf.buf, ""); + + apr_pool_destroy(pool); +} + +/* Test: Simple string without special characters */ +static void test_json_escape_simple_string(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + apr_strbuf_t buf; + char *initial = apr_palloc(pool, 256); + apr_strbuf_init(pool, &buf, initial, 256); + + append_json_string(pool, &buf, "hello world"); + + assert_string_equal(buf.buf, "hello world"); + + apr_pool_destroy(pool); +} + +/* Test: String with double quotes */ +static void test_json_escape_quotes(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + apr_strbuf_t buf; + char *initial = apr_palloc(pool, 256); + apr_strbuf_init(pool, &buf, initial, 256); + + append_json_string(pool, &buf, "hello \"world\""); + + assert_string_equal(buf.buf, "hello \\\"world\\\""); + + apr_pool_destroy(pool); +} + +/* Test: String with backslashes */ +static void test_json_escape_backslashes(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + apr_strbuf_t buf; + char *initial = apr_palloc(pool, 256); + apr_strbuf_init(pool, &buf, initial, 256); + + append_json_string(pool, &buf, "path\\to\\file"); + + assert_string_equal(buf.buf, "path\\\\to\\\\file"); + + apr_pool_destroy(pool); +} + +/* Test: String with newlines and tabs */ +static void test_json_escape_newlines_tabs(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + apr_strbuf_t buf; + char *initial = apr_palloc(pool, 256); + apr_strbuf_init(pool, &buf, initial, 256); + + append_json_string(pool, &buf, "line1\nline2\ttab"); + + assert_string_equal(buf.buf, "line1\\nline2\\ttab"); + + apr_pool_destroy(pool); +} + +/* Test: String with control characters */ +static void test_json_escape_control_chars(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + apr_strbuf_t buf; + char *initial = apr_palloc(pool, 256); + apr_strbuf_init(pool, &buf, initial, 256); + + /* Test with bell character (0x07) */ + append_json_string(pool, &buf, "test\bell"); + + /* Should contain unicode escape */ + assert_true(strstr(buf.buf, "\\u0007") != NULL); + + apr_pool_destroy(pool); +} + +/* Test: NULL string */ +static void test_json_escape_null_string(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + apr_strbuf_t buf; + char *initial = apr_palloc(pool, 256); + apr_strbuf_init(pool, &buf, initial, 256); + + append_json_string(pool, &buf, NULL); + + assert_string_equal(buf.buf, ""); + + apr_pool_destroy(pool); +} + +/* Test: Complex user agent string */ +static void test_json_escape_user_agent(void **state) +{ + apr_pool_t *pool; + apr_pool_create(&pool, NULL); + + apr_strbuf_t buf; + char *initial = apr_palloc(pool, 512); + apr_strbuf_init(pool, &buf, initial, 512); + + const char *ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \"Test\""; + append_json_string(pool, &buf, ua); + + assert_true(strstr(buf.buf, "\\\"Test\\\"") != NULL); + + apr_pool_destroy(pool); +} + +int main(void) +{ + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_json_escape_empty_string), + cmocka_unit_test(test_json_escape_simple_string), + cmocka_unit_test(test_json_escape_quotes), + cmocka_unit_test(test_json_escape_backslashes), + cmocka_unit_test(test_json_escape_newlines_tabs), + cmocka_unit_test(test_json_escape_control_chars), + cmocka_unit_test(test_json_escape_null_string), + cmocka_unit_test(test_json_escape_user_agent), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +}