99 lines
4.1 KiB
PHP
99 lines
4.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
namespace App\Services\Freenode;
|
|
|
|
use RuntimeException;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
/** Native core checks run per complete config, with bisection only when a config fails. */
|
|
final class NativeValidator
|
|
{
|
|
public int $checks = 0;
|
|
public array $rejected = [];
|
|
private string $dir;
|
|
private float $startedAt;
|
|
private ?int $failureIndex = null;
|
|
|
|
public function __construct(private string $singbox, private string $mihomo, private ?array $clashTemplate = null, ?string $dataDirectory = null)
|
|
{
|
|
$this->startedAt = microtime(true);
|
|
if ($dataDirectory !== null) {
|
|
foreach (['Country.mmdb', 'geoip.metadb'] as $file) {
|
|
if (!is_readable($dataDirectory.'/'.$file)) throw new RuntimeException('missing_offline_geoip');
|
|
}
|
|
}
|
|
$this->dir = sys_get_temp_dir().'/freenode-check-'.bin2hex(random_bytes(8));
|
|
mkdir($this->dir, 0700, true);
|
|
if ($dataDirectory !== null) foreach (['Country.mmdb', 'geoip.metadb'] as $file) symlink($dataDirectory.'/'.$file, $this->dir.'/'.$file);
|
|
}
|
|
|
|
public function __destruct()
|
|
{
|
|
foreach (glob($this->dir.'/*') ?: [] as $p) if (is_file($p)) @unlink($p);
|
|
@rmdir($this->dir);
|
|
}
|
|
|
|
public function check(string $format, string $content): bool
|
|
{
|
|
$binary = $format === 'singbox' ? $this->singbox : $this->mihomo;
|
|
if (!is_executable($binary)) throw new RuntimeException('native_validator_not_executable:'.$format);
|
|
$remaining = 240 - (microtime(true) - $this->startedAt);
|
|
if ($remaining <= 0) throw new RuntimeException('native_validation_batch_timeout');
|
|
$file = $this->dir.'/'.bin2hex(random_bytes(6)).($format === 'clash' ? '.yaml' : '.json');
|
|
file_put_contents($file, $content); chmod($file, 0600);
|
|
$command = $format === 'singbox' ? [$binary, 'check', '-c', $file] : [$binary, '-t', '-d', $this->dir, '-f', $file];
|
|
$process = new Process($command);
|
|
$process->setTimeout(min(45, $remaining));
|
|
try {
|
|
$process->run();
|
|
$this->checks++;
|
|
$this->failureIndex = null;
|
|
// Native diagnostics stay in memory. Extract only the structured node index.
|
|
$diagnostic = $process->getErrorOutput().$process->getOutput();
|
|
$pattern = $format === 'singbox' ? '/outbound(?:s)?\[(\d+)\]/' : '/proxy (\d+):/';
|
|
if (!$process->isSuccessful() && preg_match($pattern, $diagnostic, $match)) {
|
|
$this->failureIndex = (int) $match[1] - ($format === 'singbox' ? 2 : 0);
|
|
}
|
|
return $process->isSuccessful();
|
|
} finally {
|
|
@unlink($file);
|
|
}
|
|
}
|
|
|
|
public function filter(string $format, array $nodes): array
|
|
{
|
|
$render = fn (array $rows) => $this->render($format, $rows);
|
|
if (!$this->check($format, $render([]))) throw new RuntimeException('invalid_template_or_core:'.$format);
|
|
return $this->partition($format, $nodes, $render);
|
|
}
|
|
|
|
public function render(string $format, array $nodes): string
|
|
{
|
|
return $format === 'singbox' ? Renderer::singbox($nodes) : Renderer::clash($nodes, $this->clashTemplate);
|
|
}
|
|
|
|
private function partition(string $format, array $nodes, callable $render): array
|
|
{
|
|
if (!$nodes) return [];
|
|
while ($nodes) {
|
|
if ($this->check($format, $render($nodes))) return $nodes;
|
|
$keys = array_keys($nodes);
|
|
if ($this->failureIndex === null || !isset($keys[$this->failureIndex])) break;
|
|
$key = $keys[$this->failureIndex];
|
|
$this->rejected[$format][] = $key;
|
|
unset($nodes[$key]);
|
|
}
|
|
if (!$nodes) return [];
|
|
if (count($nodes) === 1) {
|
|
$this->rejected[$format][] = array_key_first($nodes);
|
|
return [];
|
|
}
|
|
$chunks = array_chunk($nodes, (int) ceil(count($nodes) / 2), true);
|
|
$good = [];
|
|
foreach ($chunks as $chunk) $good += $this->partition($format, $chunk, $render);
|
|
// Builder validates the final complete config once, including cross-node constraints.
|
|
return $good;
|
|
}
|
|
}
|