New_Micro_Learning/backend/app/Modules/AI/Infrastructure/AiEndpointPolicy.php

177 خطوط
6.5 KiB
PHP

<?php
namespace App\Modules\AI\Infrastructure;
use Closure;
use InvalidArgumentException;
final class AiEndpointPolicy
{
/** @var array<string, list<int>> */
private const LOCAL_PROVIDER_PORTS = [
'ollama' => [11434],
'lm_studio' => [1234],
'vllm' => [8000],
'localai' => [8080],
];
private readonly Closure $resolver;
/** @var list<string> */
private readonly array $customProviderHosts;
/**
* @param (Closure(string): list<string>)|null $resolver
* @param list<string>|null $customProviderHosts
*/
public function __construct(?Closure $resolver = null, ?array $customProviderHosts = null)
{
$this->resolver = $resolver ?? fn (string $host): array => $this->resolveHost($host);
$configuredHosts = $customProviderHosts ?? config('ai.security.custom_provider_allowed_hosts', []);
$this->customProviderHosts = array_values(array_unique(array_filter(array_map(
fn (mixed $host): string => $this->normalizeHost((string) $host),
is_array($configuredHosts) ? $configuredHosts : [],
))));
}
public function assertAllowed(string $provider, string $mode, string $baseUrl): void
{
$endpoint = $this->endpoint($baseUrl);
if (isset(self::LOCAL_PROVIDER_PORTS[$provider])) {
if ($mode !== 'local' || ! in_array($endpoint['port'], self::LOCAL_PROVIDER_PORTS[$provider], true)) {
throw new InvalidArgumentException('Local AI provider mode or port is not allowed.');
}
if (! $this->isLoopbackHost($endpoint['host']) || ! in_array($endpoint['scheme'], ['http', 'https'], true)) {
throw new InvalidArgumentException('Local AI providers are restricted to loopback addresses.');
}
return;
}
if ($provider === 'openai') {
if ($mode !== 'online' || $endpoint['scheme'] !== 'https' || $endpoint['host'] !== 'api.openai.com' || $endpoint['port'] !== 443) {
throw new InvalidArgumentException('OpenAI connections must use the official HTTPS endpoint.');
}
return;
}
if ($provider !== 'openai_compatible' || $mode !== 'online') {
throw new InvalidArgumentException('Unknown AI provider policy.');
}
if ($endpoint['scheme'] !== 'https' || $endpoint['port'] !== 443 || ! in_array($endpoint['host'], $this->customProviderHosts, true)) {
throw new InvalidArgumentException('Custom online AI providers require an exact HTTPS host allowlist entry on port 443.');
}
}
/** @return array{allow_redirects: false, curl: array<int, list<string>>} */
public function requestOptions(string $provider, string $mode, string $baseUrl): array
{
$this->assertAllowed($provider, $mode, $baseUrl);
$endpoint = $this->endpoint($baseUrl);
$addresses = ($this->resolver)($endpoint['host']);
if ($addresses === []) {
throw new InvalidArgumentException('AI provider hostname could not be resolved safely.');
}
$local = isset(self::LOCAL_PROVIDER_PORTS[$provider]);
foreach ($addresses as $address) {
if (! filter_var($address, FILTER_VALIDATE_IP)) {
throw new InvalidArgumentException('AI provider resolved to an invalid address.');
}
if ($local ? ! $this->isLoopbackIp($address) : ! $this->isPublicIp($address)) {
throw new InvalidArgumentException('AI provider resolved to a disallowed network address.');
}
}
if (! defined('CURLOPT_RESOLVE')) {
throw new InvalidArgumentException('Secure DNS pinning is unavailable in this PHP runtime.');
}
$address = str_contains($addresses[0], ':') ? '['.$addresses[0].']' : $addresses[0];
return [
'allow_redirects' => false,
'curl' => [constant('CURLOPT_RESOLVE') => ["{$endpoint['host']}:{$endpoint['port']}:{$address}"]],
];
}
/** @return array{scheme: string, host: string, port: int} */
private function endpoint(string $baseUrl): array
{
if (! filter_var($baseUrl, FILTER_VALIDATE_URL)) {
throw new InvalidArgumentException('AI provider URL is invalid.');
}
$parts = parse_url($baseUrl);
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
throw new InvalidArgumentException('AI provider URL is incomplete.');
}
if (isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) {
throw new InvalidArgumentException('AI provider URL credentials, query strings, and fragments are not allowed.');
}
$scheme = strtolower($parts['scheme']);
$host = $this->normalizeHost($parts['host']);
if ($host === '' || (! filter_var($host, FILTER_VALIDATE_IP) && ! preg_match('/^[a-z0-9.-]+$/', $host))) {
throw new InvalidArgumentException('AI provider hostname is invalid.');
}
return [
'scheme' => $scheme,
'host' => $host,
'port' => (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80)),
];
}
/** @return list<string> */
private function resolveHost(string $host): array
{
if (filter_var($host, FILTER_VALIDATE_IP)) {
return [$host];
}
if ($host === 'localhost') {
return ['127.0.0.1'];
}
$addresses = [];
foreach (dns_get_record($host, DNS_A | DNS_AAAA) ?: [] as $record) {
$address = $record['ip'] ?? $record['ipv6'] ?? null;
if (is_string($address)) {
$addresses[] = $address;
}
}
return array_values(array_unique($addresses));
}
private function normalizeHost(string $host): string
{
return strtolower(rtrim(trim($host), '.'));
}
private function isLoopbackHost(string $host): bool
{
return $host === 'localhost' || (filter_var($host, FILTER_VALIDATE_IP) && $this->isLoopbackIp($host));
}
private function isLoopbackIp(string $address): bool
{
if ($address === '::1') {
return true;
}
if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
return str_starts_with($address, '127.');
}
private function isPublicIp(string $address): bool
{
return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
}