commit b04c92ce495685529989f8e56f06355907499f3c Author: Alexander Schiemann Date: Mon May 19 23:01:42 2025 +0200 Initial commit of ip2nginx project (v0.0.1) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..703be4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +.idea +._* +.vscode +**/_* \ No newline at end of file diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..0158aae --- /dev/null +++ b/.htaccess @@ -0,0 +1,13 @@ +# Disable directory listing +Options -Indexes + + + RewriteEngine On + + # Allow direct access to update.php + RewriteCond %{REQUEST_URI} ^/update\.php$ [NC] + RewriteRule ^ - [L] + + # Route all other requests through index.php + RewriteRule ^.*$ index.php [L,QSA] + \ No newline at end of file diff --git a/.version b/.version new file mode 100644 index 0000000..89f32cd --- /dev/null +++ b/.version @@ -0,0 +1 @@ +**Version:** 0.0.1 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c43579f --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 SAFE-CAP + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d804f2c --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# ip2nginx + +**ip2nginx** is a lightweight and secure system for dynamically updating NGINX reverse proxy configurations based on IP address changes reported by remote systems (e.g., pfSense). It is particularly useful in scenarios where IP addresses change dynamically and need to be synchronized with a web server’s reverse proxy configuration. + +## ⚙️ Features + +- Accepts remote updates via update.php (must be present on the server). +- Logs all changes to log.json. +- Access is controlled using tokens stored in token.json. +- Protects against abuse by blocking IPs after repeated failed attempts, using blocklist.json and failures.json. +- The core function is to locate a specific location block inside the domain’s nginx.conf and update only the proxy_pass value — without overwriting the entire file. +- Automatically reloads NGINX using nginx -t && systemctl reload nginx. + +## 📁 Project Structure +``` +ip2nginx/ +├── .htaccess # Restricts access, routes all requests to updater +├── updater.php # Applies proxy_pass updates to nginx.conf +├── update.php # Processes and applies external IP update requests +├── run.sh # CLI wrapper script for triggering update +├── index.php # Default entry point and fallback error handling +├── data/ # Data storage directory +│ ├── meta.json # Central metadata file for all domains +│ ├── token.json # Authorized tokens for each client +│ ├── log.json # Log of updates and timestamps +│ ├── blocklist.json # IPs blocked after repeated failed attempts +│ └── failures.json # Failed authentication tracking +``` + +## ✅ Example Entry in `token.json` + +```json +{ + "domain1.to.com": "SECRET_TOKEN_8v73jDKsdLzAq9DkeUz1", + "domain2.to.com": "SECRET_TOKEN_3im83jUj28mjo2mI23un" +} +``` + + +## ✅ Example Entry in `meta.json` + +```json +{ + "domain1.to.com": { + "domain": "domain.from.com", + "ip": "192.0.2.4", + "port": "443", + "protocol": "https", + "location": "/", + "time": "2025-05-16T09:00:00+00:00", + "changed": 1 + } +} +``` + +## 🚀 Remote update Process: `update.php` + +The API supports both GET and POST requests, though POST is recommended to prevent token caching by proxies or browsers. + +| Parameter | Required | Description | +|------------|----------|-----------------------------------------------------------------------------| +| `name` | ✅ | Identifier for the configuration entry (e.g., `domain1.to.com`) | +| `token` | ✅ | Secret token assigned to the client for authentication | +| `ip` | ❌ | New public IP address (auto-detected if omitted) | +| `domain` | ❌ | Target domain name to connect to (e.g., `domain.from.com`) | +| | | (default: IP address) | +| `port` | ❌ | Proxy port (default: `443` for HTTPS, `80` for HTTP) | +| `protocol` | ❌ | Protocol to use: `http` or `https` (default: `http`) | +| `location` | ❌ | Specific `location` block in NGINX to update (default: `/`) | + +If the domain, IP, port, or protocol differ from the previously saved values, the entry in `meta.json` is automatically marked with `"changed": 1`. + + +## 🚀 Local update Process: `updater.php` + +This script is meant to be run via CLI (e.g. cronjob). It will: + +1. Load all entries from `meta.json` +2. Process only those marked as `"changed": 1` +3. Locate the virtual host config: `/var/www/vhosts/system//conf/nginx.conf` +4. Replace only the `proxy_pass` inside the matching `location` block +5. Validate and reload Nginx (`sudo nginx -t && sudo systemctl reload nginx`) +6. Reset the `"changed"` flag in `meta.json` + +## ⏱ Cronjob Setup: Running `run.sh` Every 5 Minutes + +To automatically apply updates to `nginx.conf` when new data is received via `update.php`, you should schedule the `run.sh` script to run regularly. This ensures that any entries marked as `"changed": 1` in `meta.json` are processed and deployed without manual intervention. + +### 🔧 Recommended Cron Configuration + +To run the update script every 5 or 10 minutes as **root** (required for writing to NGINX config files and reloading the server): + +1. Open the root user’s crontab: + +```sh +sudo crontab -e +``` + +2. Add the following line: + +```cron +*/5 * * * * /path/to/ip2nginx/run.sh +``` + +Replace `/path/to/ip2nginx/` with the actual path where your `run.sh` script is located. + +### ✅ Why It Must Run as Root + +The updater modifies system-level files, typically located in: + +``` +/var/www/vhosts/system//conf/nginx.conf +``` + +These files usually require **root** privileges for writing. Additionally, reloading NGINX via: + +```sh +sudo nginx -t && sudo systemctl reload nginx +``` + +also requires elevated permissions. If `run.sh` is not run as root, the updates will silently fail or be skipped due to insufficient access rights. + +## 🛡 Security + +- `.htaccess` blocks direct access to internal logic. +- Only HTTPS connections should be used. +- Only `update.php` is directly accessible. All other access is routed through `index.php`. +- All access is name and token-authenticated +- IP addresses are blocked for 48 hours after 3 failed attempts +- API responses are intentionally generic to prevent brute-force probing + +## 🔄 Usage Example from pfSense +You can create a cron task in pfSense or use a shell script to send updated IP info to the server: + +```sh +#!/bin/sh + +SERVER="https://your-server.com/ip2nginx" +NAME="domain1.to.com" +DOMAIN="domain.from.com" +TOKEN="YOUR_SECRET_TOKEN" + +curl -s -X POST "$SERVER/update.php" \ + -d "name=$NAME" \ + -d "domain=$DOMAIN" \ + -d "protocol=https" \ + -d "port=443" \ + -d "token=$TOKEN" +``` + +OR + +curl -s -X POST "https://your-server.com/ip2nginx/update.php" "name=domain1.to.com" -d "token=SECRET_TOKEN_8v73jDKsdLzAq9DkeUz1" -d "protocol=https" + +Add this script to pfSense via `System > Advanced > Cron` and run it every 5 or 10 minutes. + +## ✅ Dependencies + +- PHP 7.4+ +- `nginx` installed with permissions to reload via `systemctl` +- `curl` for shell scripts (on client side) +- Token-based access configuration in `token.json` + +## 📜 License + +MIT or similar — free to use, modify, and deploy. + +--- + +Created by **SAFE-CAP / https://safe-cap.com / Alexander Schiemann** diff --git a/check_env.php b/check_env.php new file mode 100644 index 0000000..83ad68a --- /dev/null +++ b/check_env.php @@ -0,0 +1,91 @@ + ['default' => DEF_EXAMPLE, 'keep' => true], + METAFILE => ['default' => [], 'keep' => false], + LOGFILE => ['default' => [], 'keep' => false], + BLOCKLIST_FILE => ['default' => [], 'keep' => false], + FAILURESFILE => ['default' => [], 'keep' => false], +]; + +// Check if DATA_DIR exists and is writable +echo "- DATA_DIR: " . DATA_DIR . "\n"; +if (!is_dir(DATA_DIR)) { + echo " ❌ DATA_DIR does not exist\n"; +} elseif (!is_writable(DATA_DIR)) { + echo " ⚠️ DATA_DIR is not writable\n"; +} else { + echo " ✅ DATA_DIR OK\n"; +} + +// Check and optionally create required files +foreach ($requiredFiles as $file => $opts) { + echo "- Checking: $file\n"; + if (!file_exists($file)) { + echo " ⚠️ File not found. Creating...\n"; + saveJson($file, $opts['default']); + } else { + $opts['keep'] = true; + } + + if (!file_exists($file)) { + echo " ❌ Failed to create\n"; + continue; + } + + if (!is_readable($file)) { + echo " ⚠️ Not readable\n"; + continue; + } + + if (!is_writable($file)) { + echo " ⚠️ Not writable\n"; + continue; + } + + // Validate JSON syntax + loadJson($file); + if (json_last_error() !== JSON_ERROR_NONE) { + echo " ❌ Invalid JSON: " . json_last_error_msg() . "\n"; + } else { + echo " ✅ JSON valid\n"; + } + + if (!$opts['keep']) { + echo " ℹ️ Will be auto-deleted\n"; + unlink($file); + } +} + +// Check NGINX conf directory +echo "- NGINX base: " . VHOST_BASE . "\n"; +if (!is_dir(VHOST_BASE)) { + echo " ❌ VHOST_BASE directory missing\n"; +} elseif (!is_readable(VHOST_BASE)) { + echo " ⚠️ VHOST_BASE not readable\n"; +} else { + echo " ✅ VHOST_BASE OK\n"; +} + +echo "Check complete.\n"; diff --git a/index.php b/index.php new file mode 100644 index 0000000..b820175 --- /dev/null +++ b/index.php @@ -0,0 +1,228 @@ + "SECRET_EXAMPLE"]); + } + + // === Security settings === + + // Block duration in seconds (48 hours by default) + if (!defined('BLOCK_DURATION_SECONDS')) { + define('BLOCK_DURATION_SECONDS', 48 * 3600); // Duration of IP block after failures + } + +} else { + // If the file was accessed directly, deny access + error_deny(); +} + +/** + * Sends a standard 403 Forbidden HTTP response with an HTML message + * and terminates the script. + */ +function error_deny() : void { + // Set HTTP response status code to 403 + http_response_code(403); + + // Explicitly send HTTP headers for 403 Forbidden and HTML content type + header('HTTP/1.1 403 Forbidden'); + header('Content-Type: text/html; charset=utf-8'); + + // Output a simple HTML page explaining the forbidden access + echo "\n"; + echo "\n"; + echo "403 Forbidden\n"; + echo "\n"; + echo "\n"; + echo "

Forbidden

\n"; + echo "You do not have permission to access this document.\n"; + echo "

\n"; + echo "


\n"; + echo "
\n"; + echo "Web Server at " . $_SERVER['SERVER_NAME'] . "\n"; + echo "
\n"; + echo "\n"; + echo "\n"; + + // Exit the script with status code 403 + exit(403); +} + +/** + * Retrieves a parameter from the request (POST takes precedence over GET). + * If the parameter is not found in either, returns the provided default value. + * + * @param string $name The name of the parameter to retrieve + * @param mixed $def The default value to return if parameter is not found + * @return mixed The value of the parameter or the default value + */ +function getQuery(string $name, mixed $def): mixed { + $val = $_POST[$name] ?? null; + + if ($val === null) { + $val = $_GET[$name] ?? $def; + } + + return $val; +} + +/** + * Checks if a given value is considered "empty". + * - Returns true for null, empty strings, empty arrays, 0, "0", etc. (like PHP's `empty()`) + * - Additionally, for strings, also trims whitespace before checking emptiness + * + * @param mixed $val The value to evaluate + * @return bool True if the value is empty or whitespace-only, false otherwise + */ +function isEmpty(mixed $val): bool { + // Handle typical empty values (null, "", 0, [], false, etc.) + if (empty($val)) { + return true; + } + + // If it's a string, trim and check again + if (is_string($val)) { + if (trim($val) === '') { + return true; + } + } + + return false; +} + +/** + * Loads and parses a JSON file into an associative array. + * + * Checks if the file exists and is readable before attempting to decode it. + * If the JSON is invalid, an error message is printed. + * If decoding fails or the result is not an array, an empty array is returned. + * + * @param string $filename Path to the JSON file + * @return array Parsed associative array or empty array on error + */ +function loadJson(string $filename): array { + $result = []; + + // Attempt to read and decode the file if it exists and is readable + if (file_exists($filename) && is_readable($filename)) { + $result = json_decode(file_get_contents($filename), true); + + // Log an error if JSON is invalid + if (json_last_error() !== JSON_ERROR_NONE) { + echo "Invalid JSON in $filename: " . json_last_error_msg() . "\n"; + } + } + + // Ensure the result is an array + if (!is_array($result)) { + $result = []; + } + + return $result; +} + +/** + * Saves a PHP array to a JSON file in pretty format. + * + * - Ensures the target file is writable if it already exists. + * - Automatically replaces null or empty input with an empty array. + * - Returns true on success, false on any failure. + * + * @param string $filename The path to the JSON file to write + * @param array|null $val The data to save (null or empty → []) + * @return bool True if saved successfully, false otherwise + */ +function saveJson(string $filename, ?array $val): bool { + // If file exists, check for write permissions + if (file_exists($filename)) { + if (!is_writable($filename)) { + echo "No access to write in $filename\n"; + return false; + } + } + + // Default to empty array if null or empty + if (isEmpty($val)) { + $val = []; + } + + // Encode to JSON + $encoded = json_encode($val, JSON_PRETTY_PRINT); + if ($encoded === false) { + echo "Failed to encode $filename: " . json_last_error_msg() . "\n"; + return false; + } + + // Save to file + if (file_put_contents($filename, $encoded) === false) { + echo "Failed to write to $filename\n"; + return false; + } + + return true; +} + + diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..7649d70 --- /dev/null +++ b/run.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Resolve absolute path to the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Run the PHP updater script +/usr/bin/env php "$SCRIPT_DIR/updater.php" diff --git a/update.php b/update.php new file mode 100644 index 0000000..f02d63c --- /dev/null +++ b/update.php @@ -0,0 +1,231 @@ + $domain, + 'ip' => $ip, + 'port' => $port, + 'protocol' => $protocol, + ]; + + // Existing data for this entry, if any + $existing = $meta[$name] ?? []; + + // If any changes detected — update + if (array_diff_assoc($incoming, $existing)) { + $incoming['location'] = DEF_LOCATION; // Default location + $incoming['time'] = date('c'); // Timestamp + $incoming['changed'] = 1; // Flag as changed + + // Preserve existing location if defined and not empty + if (array_key_exists('location', $existing)) { + if (!isEmpty($existing['location'])) { + $incoming['location'] = $existing['location']; + } + } + + // Save updated meta + $meta[$name] = $incoming; + + saveJson (METAFILE, $meta); + + // Log the change + logChange ($name, $incoming); + $resp = "Updated: [$protocol://$ip:$port] for $name\n"; + } + + // Always respond with 200 OK and a message + http_response_code(200); + echo $resp; +} + + +/** + * Checks whether a given IP address is currently blocked, + * based on a JSON file containing expiration timestamps. + * + * @param string $ip IP address to check + * @return bool True if the IP is currently blocked, false otherwise + */ +function isBlockedIP(string $ip): bool { + // Load blocklist from file, or use an empty list if file is missing + $blocklist = loadJson(BLOCKLIST_FILE); + + // Return true if IP exists in blocklist and its expiration time is in the future + return isset($blocklist[$ip]) && time() < $blocklist[$ip]; +} + + +/** + * Ensures that the specified directory (or its parent) exists. + * + * If the provided path is a file, the function checks the directory it would belong to. + * If the directory does not exist and $create is true, it attempts to create it. + * + * @param string $path The path to check (can be a file or a directory) + * @param bool $create Whether to create the directory if it doesn't exist (default: true) + * @return bool True if the directory exists or was successfully created, false otherwise + */ +function ensureDirectoryExists(string $path, bool $create = true): bool { + // Determine the directory: if it's not already a dir, get its parent + $dir = is_dir($path) ? $path : dirname($path); + + // If the directory doesn't exist and we're allowed to create it + if (!is_dir($dir) && $create) { + return mkdir($dir, 0755, true); + } + + // Directory exists or was not created intentionally + return true; +} + + +/** + * Appends a structured log entry to the log file. + * + * Each log entry is an associative array containing the provided data, + * merged with a `name` identifier. The function preserves existing log + * entries by appending to the decoded array before writing back to the file. + * + * @param string $name Identifier for the log entry (e.g. domain or config name) + * @param array $data Additional data to include in the log entry + * @return void + */ +function logChange(string $name, array $data): void { + // Load existing log data or start with an empty array + $log = loadJson(LOGFILE); + + // Merge the name into the entry and append + $log[] = array_merge(['name' => $name], $data); + + // Write updated log back to file + saveJson(LOGFILE, $log); +} + +/** + * Records a failed authentication attempt for the given IP address. + * + * Tracks the number of failures in `failures.json`. If an IP reaches + * the failure threshold (e.g., 3), it is added to the blocklist with + * an expiration timestamp defined by `BLOCK_DURATION_SECONDS`. + * + * @param string $ip The IP address that failed authentication + * @return void + */ +function recordFailure(string $ip): void { + // Load the current failure count from the failures file + $failures = loadJson(FAILURESFILE); + + // Increment failure count for the IP + $failures[$ip] = ($failures[$ip] ?? 0) + 1; + + // If threshold exceeded, add IP to blocklist and reset failure count + if ($failures[$ip] >= 3) { + $blocklist = loadJson(BLOCKLIST_FILE); + $blocklist[$ip] = time() + BLOCK_DURATION_SECONDS; + + saveJson (BLOCKLIST_FILE, $blocklist); + unset ($failures[$ip]); // Reset after blocking + } + + // Save updated failure log + saveJson (FAILURESFILE, $failures); +} + diff --git a/updater.php b/updater.php new file mode 100644 index 0000000..02b8f29 --- /dev/null +++ b/updater.php @@ -0,0 +1,174 @@ + $meta) { + if (!isset($meta['changed']) || !$meta['changed']) { + continue; + } + + // Process and detect if actual change occurred + $changed |= processDomain($name, $meta); + + // Reset 'changed' flag after processing + $domains[$name]['changed'] = 0; + } + + // Restart nginx if any updates were made and save new state + if ($changed) { + shell_exec("sudo nginx -t && sudo systemctl reload nginx"); + echo "Reloaded nginx configuration.\n"; + + // Save updated meta.json back to file + if (!saveJson(METAFILE, $domains)) { + exit(1); + } + } + + echo "All applicable domains processed.\n"; +} + +/** + * Modify the proxy_pass value inside a specific location block + */ +function updateProxyPassInLocation(string $nginxConf, string $locationPath, string $newProxyPass): string { + $lines = explode("\n", $nginxConf); // Split config into lines + $inBlock = false; // Flag: inside target location block + $locationStart = false; + $braceLevel = 0; // Tracks nested braces + + foreach ($lines as $i => $line) { + $escapedLocation = preg_quote($locationPath, '/'); // Escape location for regex + if (preg_match("/^\s*location\s+$escapedLocation\s*\{\s*$/", $line)) { + $inBlock = true; + $locationStart = true; + $braceLevel = 1; + continue; + } + + if ($inBlock) { + if (strpos($line, '{') !== false) $braceLevel++; + if (strpos($line, '}') !== false) $braceLevel--; + + // Replace existing proxy_pass directive + if (preg_match("/proxy_pass\\s+.+;/", $line)) { + $lines[$i] = preg_replace("/proxy_pass\\s+.+;/", "proxy_pass $newProxyPass;", $line); + } + + // Exit block if all braces are closed + if ($braceLevel === 0) { + $inBlock = false; + } + } + } + + return implode("\n", $lines); // Return modified config +} + +/** + * Processes a single domain configuration from meta.json. + * + * - Verifies all required fields are present and non-empty. + * - Constructs the expected proxy_pass value from protocol, domain, and port. + * - Replaces only the proxy_pass directive within the specified location block. + * - If the config changes, it is saved and NGINX will be marked for reload. + * - Even if no actual config differences are found, a restart can still be triggered + * by manually setting `"changed": 1` in meta.json. + * + * @param string $name The key in meta.json (usually target vhost name) + * @param array $meta The domain's metadata including IP, protocol, port, location, etc. + * @return bool True if a reload should be performed, false otherwise + */ +function processDomain(string $name, array $meta): bool { + $requiredFields = ['domain', 'ip', 'protocol', 'port', 'location']; // Required keys + $missing = []; + $result = false; + + // Collect any missing or empty required fields + foreach ($requiredFields as $field) { + if (isEmpty($meta[$field])) { + $missing[] = $field; + } + } + + // Skip this domain if required fields are missing + if (!isEmpty($missing)) { + echo "Skipping '$name': missing or empty field(s): " . implode(', ', $missing) . "\n"; + return false; + } + + // Allow forced reloads even without content change by setting "changed": 1 in meta.json + $result = boolval($meta['changed'] ?? 1); + if (!$result) { + echo "No changes for $name\n"; + return false; + } + + // Full path to nginx config file for this domain + $systemConfPath = VHOST_BASE . '/' . $name . '/conf/' . NGINX_CONF_NAME; + + // Check file existence and permissions + if (!file_exists($systemConfPath)) { + echo "System config not found for $name\n"; + return false; + } + if (!is_readable($systemConfPath) || !is_writable($systemConfPath)) { + echo "Permission denied for $systemConfPath\n"; + return false; + } + + // Read current nginx config + $original = file_get_contents($systemConfPath); + + // Build new proxy_pass target + $proxyTarget = $meta['protocol'] . '://' . $meta['domain'] . ':' . $meta['port']; + + // Apply transformation inside matching location block + $modified = updateProxyPassInLocation($original, $meta['location'], $proxyTarget); + + // Write only if content changed + if ($original !== $modified) { + $result = (file_put_contents($systemConfPath, $modified) !== false); + echo "Updated proxy_pass for $name\n"; + } else { + echo "No changes for $name\n"; + } + + return $result; +}