177 خطوط
6.5 KiB
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;
|
|
}
|
|
}
|