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; } }