Initial commit of ip2nginx project (v0.0.1)

This commit is contained in:
Alexander Schiemann 2025-05-19 23:01:42 +02:00
commit b04c92ce49
10 changed files with 929 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.DS_Store
.idea
._*
.vscode
**/_*

13
.htaccess Normal file
View File

@ -0,0 +1,13 @@
# Disable directory listing
Options -Indexes
<IfModule mod_rewrite.c>
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]
</IfModule>

1
.version Normal file
View File

@ -0,0 +1 @@
**Version:** 0.0.1

9
LICENSE Normal file
View File

@ -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.

170
README.md Normal file
View File

@ -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 servers 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 domains 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/<domain>/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 users 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/<domain>/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**

91
check_env.php Normal file
View File

@ -0,0 +1,91 @@
<?php
// Define a constant to indicate index.php is included, not accessed directly
define('FILE_IS_INCLUDED', true);
// Include the index.php file, which contains shared configuration and logic
include('index.php');
// Only allow this script to run from the command line interface
if (php_sapi_name() !== 'cli') {
error_deny(); // Deny access if run from a browser or non-CLI environment
}
/**
* This script checks environment integrity:
* - Verifies required constants
* - Validates directory and file permissions
* - Creates required files if missing
* - Verifies JSON syntax
*/
echo "== ip2nginx Environment Check ==\n";
// List of required files with their default content and persistence flag
$requiredFiles = [
TOKENFILE => ['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";

228
index.php Normal file
View File

@ -0,0 +1,228 @@
<?php
/**
* Initializes core constants used across the ip2nginx project.
*
* This script is **only** intended to be included by other scripts.
* It prevents direct execution and ensures all key configuration constants are defined.
*/
// If the script is included and the FILE_IS_INCLUDED flag is defined,
// proceed to define the required constants.
if (defined('FILE_IS_INCLUDED')) {
// === Core data paths ===
// Define DATA_DIR if not already defined (used to store data files)
if (!defined('DATA_DIR')) {
define('DATA_DIR', __DIR__ . '/data'); // Directory where data files are stored
}
// Define METAFILE if not already defined (path to meta.json file)
if (!defined('METAFILE')) {
define('METAFILE', DATA_DIR . '/meta.json'); // Central file for domain metadata
}
// === Nginx configuration settings ===
// Define VHOST_BASE if not already defined
if (!defined('VHOST_BASE')) {
define('VHOST_BASE', '/var/www/vhosts/system'); // Base path to vhost configs (Plesk)
}
// Define NGINX_CONF_NAME if not already defined
if (!defined('NGINX_CONF_NAME')) {
define('NGINX_CONF_NAME', 'nginx.conf'); // Target file to modify inside each vhost
}
// === Additional configuration ===
// Define blocklist file
if (!defined('BLOCKLIST_FILE')) {
define('BLOCKLIST_FILE', DATA_DIR . '/blocklist.json'); // File storing blocked IPs
}
// Define authentication failure tracking file
if (!defined('FAILURESFILE')) {
define('FAILURESFILE', DATA_DIR . '/failures.json'); // Tracks failed login attempts
}
// Define general log file
if (!defined('LOGFILE')) {
define('LOGFILE', DATA_DIR . '/log.json'); // Change log
}
// Define API token file
if (!defined('TOKENFILE')) {
define('TOKENFILE', DATA_DIR . '/token.json'); // Token registry
}
// Define default location block used if none is set
if (!defined('DEF_LOCATION')) {
define('DEF_LOCATION', '/'); // Default nginx location path
}
// Define default example block for meta.json
if (!defined('DEF_EXAMPLE')) {
define('DEF_EXAMPLE', ["example.com" => "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 "<HTML>\n";
echo "<HEAD>\n";
echo "<TITLE>403 Forbidden</TITLE>\n";
echo "</HEAD>\n";
echo "<BODY>\n";
echo "<H1>Forbidden</H1>\n";
echo "You do not have permission to access this document.\n";
echo "<P>\n";
echo "<HR>\n";
echo "<ADDRESS>\n";
echo "Web Server at " . $_SERVER['SERVER_NAME'] . "\n";
echo "</ADDRESS>\n";
echo "</BODY>\n";
echo "</HTML>\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;
}

7
run.sh Normal file
View File

@ -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"

231
update.php Normal file
View File

@ -0,0 +1,231 @@
<?php
// Define a constant to indicate index.php is included, not accessed directly
define('FILE_IS_INCLUDED', true);
// Include the index.php file, which contains shared configuration and logic
include('index.php');
/**
* Main logic handler
*/
main();
function main(): void {
// Ensure the data directory exists and is writable
if (!ensureDirectoryExists(DATA_DIR)) {
error_deny();
}
// Get client IP address
$clientIp = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
// Deny access if the client IP is currently blocked
if (isBlockedIP($clientIp)) {
error_deny();
}
// Retrieve parameters from GET or POST
$name = getQuery('name', null);
$domain = getQuery('domain', null);
$token = getQuery('token', null);
$ip = getQuery('ip', $clientIp);
$port = getQuery('port', 80);
$protocol = getQuery('protocol', 'http');
// Load the authorized tokens
$tokens = loadTokens();
// Validate required input and IP address format
if (isEmpty($name) || isEmpty($token) || !filter_var($ip, FILTER_VALIDATE_IP)) {
error_deny();
}
// Check if token is valid for the given name
if (!array_key_exists($name, $tokens) || $tokens[$name] !== $token) {
recordFailure($clientIp);
error_deny();
}
// Ensure the protocol is either http or https
if (!in_array($protocol, ['http', 'https'])) {
error_deny();
}
// If port is not set, use default based on protocol
if ($port === null || $port === '') {
$port = ($protocol === 'https') ? '443' : '80';
} elseif (!preg_match('/^\d{2,5}$/', $port)) {
error_deny();
}
if ($domain === null || $domain === '') {
$domain = $ip;
}
// Update metadata and log the change
updateMeta($name, $domain, $ip, $port, $protocol);
}
/**
* Loads token list from TOKENFILE or creates an example if file does not exist
*/
function loadTokens(): array {
if (!file_exists(TOKENFILE)) {
saveJson(TOKENFILE, DEF_EXAMPLE);
echo "Token file created at " . TOKENFILE . ". Please edit it and rerun.\n";
exit (1);
}
return loadJson(TOKENFILE);
}
/**
* Updates the meta.json file with new connection data for a given name (domain),
* and logs the update if changes are detected.
*
* If any field (IP, port, protocol, domain) differs from the stored data,
* the entry is updated and flagged with "changed": 1. The location field is
* preserved if it already exists and is not empty.
*
* @param string $name The identifier name (usually domain alias)
* @param string $domain The target domain for proxy_pass
* @param string $ip The updated IP address
* @param int $port The proxy port to use
* @param string $protocol Protocol to use ('http' or 'https')
* @return void
*/
function updateMeta(string $name, string $domain, string $ip, int $port, string $protocol): void {
$meta = loadJson(METAFILE);
$resp = "No changes for $name\n";
// New data coming in from client
$incoming = [
'domain' => $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);
}

174
updater.php Normal file
View File

@ -0,0 +1,174 @@
<?php
// Define a constant to indicate index.php is included, not accessed directly
define('FILE_IS_INCLUDED', true);
// Include the index.php file, which contains shared configuration and logic
include('index.php');
// Only allow this script to run from the command line interface
if (php_sapi_name() !== 'cli') {
error_deny(); // Deny access if run from a browser or non-CLI environment
}
/**
* Main logic handler
*/
main();
/**
* Main routine that processes domain updates based on `meta.json`.
*
* - Loads domain configuration data.
* - Applies updates for domains where `"changed": 1`.
* - Resets the change flag after successful update.
* - Reloads NGINX only if at least one domain was updated.
* - Saves the updated metadata back to disk.
*
* Exits with error code 1 on failure to save updated data.
*/
function main(): void {
// Load content of meta.json
$domains = loadJson(METAFILE);
// Ensure meta is valid and contains domain entries
if (isEmpty($domains)) {
echo "No valid domains in meta.json\n";
exit(1);
}
// Loop through all domains and apply updates if 'changed' flag is set
$changed = false;
foreach ($domains as $name => $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;
}