Files
2026-07-29 22:52:47 +05:00

362 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Работа с прокси: парсинг, хранение, ротация, health-check.
*
* @package AI_Proxy_Manager
*/
defined( 'ABSPATH' ) || exit;
/**
* Класс управления прокси-серверами.
*
* @since 1.0.0
*/
class AIPMSP_Proxy {
/**
* Возвращает все настройки плагина с применёнными значениями по умолчанию.
*
* @since 1.0.0
*
* @return array
*/
public static function get_settings() {
$defaults = AIPMSP_Plugin::get_default_settings();
$settings = get_option( AIPMSP_OPTION_KEY, array() );
if ( ! is_array( $settings ) ) {
$settings = array();
}
$settings = wp_parse_args( $settings, $defaults );
// Гарантируем структуру вложенных массивов.
if ( ! is_array( $settings['proxies'] ) ) {
$settings['proxies'] = array();
}
if ( ! is_array( $settings['domains'] ) ) {
$settings['domains'] = $defaults['domains'];
}
$settings['keys'] = wp_parse_args(
is_array( $settings['keys'] ) ? $settings['keys'] : array(),
$defaults['keys']
);
$settings['key_enabled'] = wp_parse_args(
isset( $settings['key_enabled'] ) && is_array( $settings['key_enabled'] ) ? $settings['key_enabled'] : array(),
$defaults['key_enabled']
);
return $settings;
}
/**
* Сохраняет настройки плагина.
*
* @since 1.0.0
*
* @param array $settings Настройки.
* @return void
*/
public static function save_settings( array $settings ) {
update_option( AIPMSP_OPTION_KEY, $settings );
}
/**
* Парсит многострочный список прокси в массив структур.
*
* Формат строки: ЛОГИН:ПАРОЛЬ@IP:ПОРТ либо IP:ПОРТ (без авторизации).
*
* @since 1.0.0
*
* @param string $raw Сырой текст из textarea.
* @param string $type Тип прокси (http|https|socks5).
* @param array $existing Ранее сохранённые прокси (для сохранения состояния enabled и паролей).
* @return array Список нормализованных прокси.
*/
public static function parse_proxies( $raw, $type, $existing = array() ) {
$type = self::sanitize_type( $type );
$result = array();
$lines = preg_split( '/\r\n|\r|\n/', (string) $raw );
// Индекс по "ip:port" для переноса состояния enabled из прежних настроек.
$existing_map = array();
foreach ( (array) $existing as $ex ) {
if ( isset( $ex['ip'], $ex['port'] ) ) {
$existing_map[ $ex['ip'] . ':' . $ex['port'] ] = $ex;
}
}
foreach ( $lines as $line ) {
$line = trim( $line );
if ( '' === $line ) {
continue;
}
$user = '';
$pass = '';
$hostport = $line;
// Если есть авторизация — отделяем её по последнему '@'
// (пароль может содержать '@', а host:port после него — нет).
if ( false !== strpos( $line, '@' ) ) {
$at_pos = strrpos( $line, '@' );
$auth = substr( $line, 0, $at_pos );
$hostport = substr( $line, $at_pos + 1 );
// Лимит 2: пароль может содержать двоеточия.
$auth_parts = explode( ':', $auth, 2 );
$user = isset( $auth_parts[0] ) ? trim( $auth_parts[0] ) : '';
$pass = isset( $auth_parts[1] ) ? $auth_parts[1] : '';
}
// Разбираем host:port.
$hp = explode( ':', $hostport, 2 );
$ip = isset( $hp[0] ) ? trim( $hp[0] ) : '';
$port = isset( $hp[1] ) ? trim( $hp[1] ) : '';
// Валидация IP/хоста и порта.
$valid_host = filter_var( $ip, FILTER_VALIDATE_IP ) || self::is_valid_host( $ip );
$port_int = absint( $port );
if ( ! $valid_host || $port_int < 1 || $port_int > 65535 ) {
continue;
}
$key = $ip . ':' . $port_int;
$enabled = 1;
if ( isset( $existing_map[ $key ]['enabled'] ) ) {
$enabled = (int) $existing_map[ $key ]['enabled'];
}
$sanitized_user = sanitize_text_field( $user );
// Пароль хранится зашифрованным и НЕ показывается в форме.
// Если в строке пароль не введён — сохраняем ранее сохранённый
// (для того же ip:port и логина), иначе шифруем новый.
if ( '' !== $pass ) {
$enc_pass = AIPMSP_Crypto::encrypt( $pass );
} elseif (
isset( $existing_map[ $key ]['pass'], $existing_map[ $key ]['user'] )
&& '' !== (string) $existing_map[ $key ]['pass']
&& $existing_map[ $key ]['user'] === $sanitized_user
) {
$enc_pass = $existing_map[ $key ]['pass'];
} else {
$enc_pass = '';
}
$result[] = array(
'ip' => $ip,
'port' => $port_int,
'user' => $sanitized_user,
'pass' => $enc_pass,
'type' => $type,
'enabled' => $enabled ? 1 : 0,
);
}
return $result;
}
/**
* Возвращает список включённых прокси.
*
* @since 1.0.0
*
* @param array|null $settings Настройки (если не переданы — загружаются).
* @return array
*/
public static function get_enabled_proxies( $settings = null ) {
if ( null === $settings ) {
$settings = self::get_settings();
}
$enabled = array();
foreach ( $settings['proxies'] as $proxy ) {
if ( ! empty( $proxy['enabled'] ) ) {
$enabled[] = $proxy;
}
}
return $enabled;
}
/**
* Проверяет один прокси через тестовый cURL-запрос.
*
* @since 1.0.0
*
* @param array $proxy Структура прокси.
* @param string $test_url URL для проверки.
* @return array Результат: success(bool), message(string), time(float ms), http_code(int).
*/
public static function health_check( array $proxy, $test_url = 'https://api.openai.com/v1/models' ) {
if ( ! function_exists( 'curl_init' ) ) {
return array(
'success' => false,
'message' => __( 'cURL недоступен на сервере', 'ai-proxy-manager' ),
'time' => 0,
'http_code' => 0,
);
}
$ch = curl_init(); // phpcs:ignore WordPress.WP.AlternativeFunctions
$proxy_type = self::curl_proxy_type( $proxy['type'] );
$pass = isset( $proxy['pass'] ) ? AIPMSP_Crypto::decrypt( $proxy['pass'] ) : '';
curl_setopt( $ch, CURLOPT_URL, $test_url ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_NOBODY, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_PROXY, $proxy['ip'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_PROXYPORT, (int) $proxy['port'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_PROXYTYPE, $proxy_type ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_setopt( $ch, CURLOPT_TIMEOUT, 15 ); // phpcs:ignore WordPress.WP.AlternativeFunctions
if ( ! empty( $proxy['user'] ) && '' !== $pass ) {
curl_setopt( $ch, CURLOPT_PROXYUSERPWD, $proxy['user'] . ':' . $pass ); // phpcs:ignore WordPress.WP.AlternativeFunctions
}
$start = microtime( true );
$exec = curl_exec( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
$elapsed = round( ( microtime( true ) - $start ) * 1000 );
$http_code = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE ); // phpcs:ignore WordPress.WP.AlternativeFunctions
$err_no = curl_errno( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
$err_msg = curl_error( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
curl_close( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions
if ( 0 !== $err_no || false === $exec ) {
return array(
'success' => false,
'message' => sprintf(
/* translators: %s: текст ошибки cURL */
__( 'Недоступен: %s', 'ai-proxy-manager' ),
$err_msg
),
'time' => $elapsed,
'http_code' => $http_code,
);
}
// Любой HTTP-ответ означает, что соединение через прокси установлено.
return array(
'success' => true,
'message' => sprintf(
/* translators: 1: HTTP-код, 2: время ответа в мс */
__( 'Доступен (HTTP %1$d, %2$d мс)', 'ai-proxy-manager' ),
$http_code,
$elapsed
),
'time' => $elapsed,
'http_code' => $http_code,
);
}
/**
* Преобразует строковый тип прокси в константу CURLPROXY_*.
*
* @since 1.0.0
*
* @param string $type Тип (http|https|socks5).
* @return int
*/
public static function curl_proxy_type( $type ) {
switch ( self::sanitize_type( $type ) ) {
case 'socks5':
return defined( 'CURLPROXY_SOCKS5' ) ? CURLPROXY_SOCKS5 : 5;
case 'https':
// HTTPS-прокси использует тот же тип-хэндлер, что и HTTP, но через TLS-соединение.
return defined( 'CURLPROXY_HTTP' ) ? CURLPROXY_HTTP : 0;
case 'http':
default:
return defined( 'CURLPROXY_HTTP' ) ? CURLPROXY_HTTP : 0;
}
}
/**
* Нормализует строковый тип прокси.
*
* @since 1.0.0
*
* @param string $type Тип.
* @return string Один из: http|https|socks5.
*/
public static function sanitize_type( $type ) {
$type = strtolower( sanitize_text_field( (string) $type ) );
$allowed = array( 'http', 'https', 'socks5' );
return in_array( $type, $allowed, true ) ? $type : 'http';
}
/**
* Возвращает нейтральную маску для отображения наличия пароля.
*
* Символы пароля не раскрываются (включая длину) — только факт его наличия.
*
* @since 1.0.0
*
* @param string $pass Открытый пароль.
* @return string Фиксированная маска при наличии пароля, иначе пустая строка.
*/
public static function mask_password( $pass ) {
return ( '' === (string) $pass ) ? '' : '••••••••';
}
/**
* Валидирует хост (домен) по символам.
*
* @since 1.0.0
*
* @param string $host Хост.
* @return bool
*/
public static function is_valid_host( $host ) {
$host = (string) $host;
if ( '' === $host || strlen( $host ) > 253 ) {
return false;
}
return (bool) preg_match( '/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/', $host );
}
/**
* Санитизирует список доменов из textarea.
*
* @since 1.0.0
*
* @param string $raw Сырой текст.
* @return array
*/
public static function parse_domains( $raw ) {
$lines = preg_split( '/\r\n|\r|\n/', (string) $raw );
$result = array();
foreach ( $lines as $line ) {
$line = strtolower( trim( $line ) );
if ( '' === $line ) {
continue;
}
// Если вставили URL — извлекаем хост.
if ( false !== strpos( $line, '/' ) ) {
$parsed = wp_parse_url( $line );
if ( ! empty( $parsed['host'] ) ) {
$line = $parsed['host'];
}
}
if ( self::is_valid_host( $line ) && ! in_array( $line, $result, true ) ) {
$result[] = $line;
}
}
return $result;
}
}