<?php

class network_info {

    /**
     * Resolve the client IP, honoring proxy headers ONLY when REMOTE_ADDR is a
     * trusted proxy. The trusted-proxy CIDR list is loaded from
     * /etc/whp/trusted-proxies.conf (one CIDR per line, '#' comments allowed)
     * and cached in a static var per-request.
     *
     * If REMOTE_ADDR is NOT trusted, CF-Connecting-IP and X-Forwarded-For are
     * ignored entirely so an attacker can't spoof their IP to bypass the
     * brute-force lockout or defeat the nonce IP-pin.
     *
     * The fallback default (used when the config file is missing) covers
     * loopback, Docker bridges, and RFC1918 private space — sufficient for the
     * standard WHP topology where Apache fronts PHP-FPM via a local socket and
     * HAProxy/Cloudflare sit in the same private network.
     */
    public function get_client_ip() {
        static $trusted = null;
        if ($trusted === null) {
            $trusted = self::loadTrustedProxies();
        }

        $remote = $_SERVER['REMOTE_ADDR'] ?? '';
        $remote_trusted = self::ipMatchesAny($remote, $trusted);

        if ($remote_trusted) {
            // Cloudflare's CF-Connecting-IP is a single IP and takes precedence
            // when present (Cloudflare always sets it for proxied requests).
            if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
                $candidate = trim($_SERVER['HTTP_CF_CONNECTING_IP']);
                if (filter_var($candidate, FILTER_VALIDATE_IP)) {
                    return $candidate;
                }
            }
            // Some Apache/PHP setups expose the literal "CF-Connecting-IP" key
            // alongside the HTTP_*-prefixed variant. Honor that too.
            if (!empty($_SERVER['CF-Connecting-IP'])) {
                $candidate = trim($_SERVER['CF-Connecting-IP']);
                if (filter_var($candidate, FILTER_VALIDATE_IP)) {
                    return $candidate;
                }
            }
            if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
                // XFF is comma-separated; leftmost is the original client.
                // Only safe to trust the leftmost when REMOTE_ADDR is trusted —
                // a malicious client can prepend any value to XFF.
                $parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
                $candidate = trim($parts[0]);
                if (filter_var($candidate, FILTER_VALIDATE_IP)) {
                    return $candidate;
                }
            }
        }

        // Fall through: REMOTE_ADDR is the only trustworthy value.
        if (filter_var($remote, FILTER_VALIDATE_IP)) {
            return $remote;
        }
        return $remote; // last-resort: even malformed, return what we have
    }

    /**
     * Load the trusted-proxy CIDR list. Returns a list of normalized CIDR
     * strings. Falls back to safe defaults if the config file is absent.
     */
    private static function loadTrustedProxies() {
        $path = '/etc/whp/trusted-proxies.conf';
        $entries = [];
        if (is_readable($path)) {
            $lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
            if (is_array($lines)) {
                foreach ($lines as $line) {
                    $line = trim($line);
                    if ($line === '' || $line[0] === '#') {
                        continue;
                    }
                    // Allow inline comments after the CIDR.
                    if (($hash = strpos($line, '#')) !== false) {
                        $line = trim(substr($line, 0, $hash));
                    }
                    if ($line !== '') {
                        $entries[] = $line;
                    }
                }
            }
        }
        if (empty($entries)) {
            // Safe defaults: loopback, Docker bridges, RFC1918 private space.
            $entries = [
                '127.0.0.0/8',
                '::1/128',
                '172.16.0.0/12',
                '10.0.0.0/8',
            ];
        }
        return $entries;
    }

    /**
     * Test whether $ip falls inside any CIDR in $cidrs. Supports IPv4 and IPv6.
     */
    private static function ipMatchesAny($ip, array $cidrs) {
        if (!filter_var($ip, FILTER_VALIDATE_IP)) {
            return false;
        }
        foreach ($cidrs as $cidr) {
            if (self::ipInCidr($ip, $cidr)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Return true if $ip is inside $cidr (e.g. "10.0.0.0/8" or "::1/128").
     * A CIDR without a "/" is treated as a single-address match.
     */
    private static function ipInCidr($ip, $cidr) {
        if (strpos($cidr, '/') === false) {
            return $ip === $cidr;
        }
        list($subnet, $bits) = explode('/', $cidr, 2);
        $bits = (int)$bits;
        $ip_bin     = @inet_pton($ip);
        $subnet_bin = @inet_pton($subnet);
        if ($ip_bin === false || $subnet_bin === false) {
            return false;
        }
        if (strlen($ip_bin) !== strlen($subnet_bin)) {
            // Different address families; cannot match.
            return false;
        }
        $bytes_full = intdiv($bits, 8);
        $bits_extra = $bits % 8;
        if ($bytes_full > 0 && substr($ip_bin, 0, $bytes_full) !== substr($subnet_bin, 0, $bytes_full)) {
            return false;
        }
        if ($bits_extra === 0) {
            return true;
        }
        $mask = chr((0xFF << (8 - $bits_extra)) & 0xFF);
        return (ord($ip_bin[$bytes_full]) & ord($mask)) === (ord($subnet_bin[$bytes_full]) & ord($mask));
    }

}
