Refactor free node generation with native validation and reversible releases

This commit is contained in:
hellcat 2026-10-18 19:20:35 +08:00
parent ddf104f158
commit 348d34f8da
18 changed files with 1082 additions and 5 deletions

View File

@ -17,6 +17,16 @@ final class FreenodeController
public function sub($sSiteCode, $sClient, $sDate = "")
{
if (\App\Services\Freenode\Pipeline::enabled()) {
abort_unless(preg_match('/^[a-zA-Z0-9_.-]+$/D', $sSiteCode) && !str_contains($sSiteCode, '..'), 404);
abort_unless(in_array($sClient, ['v2ray', 'clash', 'singbox'], true), 404);
$store = \App\Services\Freenode\Pipeline::store();
if ($sDate !== '') abort_unless($sDate === ($store->current()['date'] ?? ''), 404);
try { $content = $store->readSite($sSiteCode, [$sClient])[$sClient]; }
catch (\RuntimeException $e) { abort(503, 'Subscription temporarily unavailable'); }
$mime = ['v2ray' => 'text/plain', 'clash' => 'application/yaml', 'singbox' => 'application/json'][$sClient];
return response($content, 200, ['Content-Type' => $mime.'; charset=utf-8', 'Cache-Control' => 'no-store']);
}
header('Content-Type: application/x-yaml; charset=utf-8');
$sDirBase = $_ENV["dir_base"];

View File

@ -245,6 +245,11 @@ final class FreenodeCrawl
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $sUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // 返回内容而不是直接输出
if (\App\Services\Freenode\Pipeline::enabled()) {
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_FAILONERROR, true);
}
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 如果是 https,可以关闭证书验证(测试用)
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); // 关闭主机名验证
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0); // 关闭主机名验证
@ -252,6 +257,10 @@ final class FreenodeCrawl
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
if (\App\Services\Freenode\Pipeline::enabled()) {
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
}
$sContent = curl_exec($ch);
if ($sContent === false) {
@ -287,12 +296,23 @@ final class FreenodeCrawl
}
// 确保文件头是 UTF-8(可选)
$content = "\xEF\xBB\xBF" . $content; // 如果需要 BOM,可以加上
$content = \App\Services\Freenode\Pipeline::enabled()
? preg_replace('/^\xEF\xBB\xBF/', '', $content)
: "\xEF\xBB\xBF".$content;
file_put_contents($filename, $content);
if (\App\Services\Freenode\Pipeline::enabled()) {
$temporary = tempnam(dirname($filename), '.crawl-');
try {
if (file_put_contents($temporary, $content) !== strlen($content)) throw new \RuntimeException('source_write_failed');
chmod($temporary, 0644);
if (!rename($temporary, $filename)) throw new \RuntimeException('source_rename_failed');
} finally { if (is_file($temporary)) unlink($temporary); }
} else {
file_put_contents($filename, $content);
}
@chmod($filename, 0777);
if (!\App\Services\Freenode\Pipeline::enabled()) @chmod($filename, 0777);
return true;
}

View File

@ -53,6 +53,7 @@ final class FreenodeMerge
public static function save()
{
if (\App\Services\Freenode\Pipeline::enabled()) return;
echo "\nFreenodeFile::save - 开始 \n";
//// test
@ -271,6 +272,13 @@ final class FreenodeMerge
public static function baseToSite($sSiteGroupCodeTom)
{
if (\App\Services\Freenode\Pipeline::enabled()) {
if ($sSiteGroupCodeTom === 'fn_a') {
$release = \App\Services\Freenode\Pipeline::store()->publishPrepared();
echo 'Freenode v2 published: '.$release['id']."\n";
}
return true;
}
echo "\nFreenodeMerge::baseToSite - 开始 \n";
$sDate = date("Y-m-d");

View File

@ -16,6 +16,11 @@ final class FreenodePool
public static function save()
{
if (\App\Services\Freenode\Pipeline::enabled()) {
$release = \App\Services\Freenode\Pipeline::prepare();
echo 'Freenode v2 prepared: '.$release['id']."\n";
return;
}
echo "FreenodePoll::save - 开始 \n";
$sDirBase = $_ENV["dir_base"];
$sDate = date("Y-m-d");

View File

@ -19,6 +19,7 @@ final class FreenodeSync
$sDirBase = $_ENV["dir_base"];
$oFnSiteList = ModelSiteMap::where("group_code", "fn_a")->get();
if (\App\Services\Freenode\Pipeline::enabled()) $oFnSiteList = $oFnSiteList->where('status', 1);
$aResultAll = [];
foreach ($oFnSiteList as $oFnSite) {
@ -29,7 +30,7 @@ final class FreenodeSync
$sSiteApiFreenodeReceive = $aSiteApi["freenode_receive"] ?? "";
$aFnContent = [];
foreach ($aSiteConfigFnClient as $aSiteConfigFnClientRow) {
if (!\App\Services\Freenode\Pipeline::enabled()) foreach ($aSiteConfigFnClient as $aSiteConfigFnClientRow) {
$sFilePath = $sDirBase."public/freenode/merge/".$oFnSite->code."/".$aSiteConfigFnClientRow."/".date("Y-m-d").".txt";
if (file_exists($sFilePath)) {
@ -40,6 +41,9 @@ final class FreenodeSync
}
}
if (\App\Services\Freenode\Pipeline::enabled()) {
$aFnContent = \App\Services\Freenode\Pipeline::store()->readSite($oFnSite->code, $aSiteConfigFnClient);
}
$sApiUrl = "https://".$oFnSite->url."/".$sSiteApiFreenodeReceive;
$sResult = HttpV2::make($sApiUrl, "post")->setDataA($aFnContent)->enableJsonSend()->send();
@ -64,6 +68,7 @@ final class FreenodeSync
$sDirBase = $_ENV["dir_base"];
$oFnSiteList = ModelSiteMap::whereIn("group_code", ["fn_b", "fn_a"])->get();
if (\App\Services\Freenode\Pipeline::enabled()) $oFnSiteList = $oFnSiteList->where('status', 1);
$oSiteNodehub = ModelSiteMap::where("code", "nodehub")->first();
// $aSiteNodehubConfig = json_decode($oSiteNodehub->config ?? '', true);
@ -79,7 +84,7 @@ final class FreenodeSync
$aData = [];
$aData["site_code_short"] = $oFnSite->code_short;
foreach ($aFreenodeClient as $aSiteConfigFnClientRow) {
if (!\App\Services\Freenode\Pipeline::enabled()) foreach ($aFreenodeClient as $aSiteConfigFnClientRow) {
$sFilePath = $sDirBase."public/freenode/merge/".$oFnSite->code."/".$aSiteConfigFnClientRow."/".date("Y-m-d").".txt";
if (!file_exists($sFilePath)) {
@ -92,6 +97,9 @@ final class FreenodeSync
$aData["raw"][$aSiteConfigFnClientRow] = file_get_contents($sFilePath);
}
if (\App\Services\Freenode\Pipeline::enabled()) {
$aData['raw'] = \App\Services\Freenode\Pipeline::store()->readSite($oFnSite->code, $aFreenodeClient);
}
$sApiUrl = "https://".$oSiteNodehub->url."/".$sSiteApiFreenodeReceive;
// tomd($sApiUrl, 1);
$sResult = HttpV2::make($sApiUrl, "post")->setDataA($aData)->enableJsonSend()->send();

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Services\Freenode;
use InvalidArgumentException;
use RuntimeException;
use Symfony\Component\Yaml\Yaml;
use Throwable;
final class Builder
{
/** No database, file publication or network side effects. */
public function build(array $sources, array $sites, NativeValidator $validator): array
{
$uri = []; $clash = []; $report = ['sources' => [], 'excluded' => [], 'nodes' => []];
foreach ($sources as $source) {
$name = $source['name'];
$stats = ['uri_read' => 0, 'clash_read' => 0, 'invalid' => 0];
foreach (preg_split('/\r\n|\n|\r/', $source['v2ray'] ?? '') as $line) {
if (trim($line) === '') continue;
$stats['uri_read']++;
try {
$node = Codec::parse($line);
$id = $node['id'];
if (!isset($uri[$id])) $uri[$id] = $node;
$uri[$id]['sources'][$name] = true;
} catch (Throwable $e) {
$stats['invalid']++;
$reason = $e instanceof InvalidArgumentException ? $e->getMessage() : 'invalid_uri';
$report['excluded'][$name][$reason] = ($report['excluded'][$name][$reason] ?? 0) + 1;
}
}
if (!empty($source['clash'])) {
try {
$data = Yaml::parse(preg_replace('/^\xEF\xBB\xBF/', '', $source['clash']));
if (!is_array($data['proxies'] ?? null)) throw new RuntimeException('no_proxies');
} catch (Throwable $e) {
throw new RuntimeException('invalid_source_yaml:'.$name);
}
foreach ($data['proxies'] as $raw) {
$stats['clash_read']++;
try {
if (!is_array($raw) || !is_string($raw['type'] ?? null)) throw new RuntimeException();
Codec::endpoint((string) ($raw['server'] ?? ''), (int) ($raw['port'] ?? 0));
$originalName = (string) ($raw['name'] ?? $raw['type']);
unset($raw['name']);
$sorted = $raw; ksort($sorted);
$id = hash('sha256', json_encode($sorted, JSON_THROW_ON_ERROR));
if (!isset($clash[$id])) $clash[$id] = ['id' => $id, 'name' => $originalName, 'data' => $raw, 'sources' => []];
$clash[$id]['sources'][$name] = true;
} catch (Throwable $e) {
$stats['invalid']++;
$report['excluded'][$name]['invalid_clash'] = ($report['excluded'][$name]['invalid_clash'] ?? 0) + 1;
}
}
}
if ($stats['uri_read'] + $stats['clash_read'] === 0) throw new RuntimeException('empty_source:'.$name);
$report['sources'][$name] = $stats;
}
if (!$uri || !$clash) throw new RuntimeException('empty_input_format');
ksort($uri); ksort($clash);
$lines = []; $singbox = []; $proxies = [];
foreach ($uri as $id => $node) {
$label = $this->label($node['name'], $id);
$line = Codec::uri($node, $label);
$roundtrip = Codec::parse($line);
if ($roundtrip['id'] !== $id) throw new RuntimeException('uri_roundtrip_failed');
$lines[$id] = $line;
$report['nodes']['uri'][$id] = array_keys($node['sources']);
try {
$singbox[$id] = Codec::singbox($node, $label);
} catch (Throwable $e) {
$reason = $e instanceof InvalidArgumentException ? $e->getMessage() : 'conversion_error';
$report['excluded']['singbox'][$reason] = ($report['excluded']['singbox'][$reason] ?? 0) + 1;
}
}
foreach ($clash as $id => $node) {
$proxies[$id] = ['name' => $this->label($node['name'], $id)] + $node['data'];
$report['nodes']['clash'][$id] = array_keys($node['sources']);
}
$singbox = $validator->filter('singbox', $singbox);
$proxies = $validator->filter('clash', $proxies);
if (!$singbox || !$proxies) throw new RuntimeException('empty_validated_format');
$outputs = ['v2ray' => base64_encode(implode("\n", $lines)), 'singbox' => $validator->render('singbox', $singbox), 'clash' => $validator->render('clash', $proxies)];
if (!$validator->check('singbox', $outputs['singbox']) || !$validator->check('clash', $outputs['clash'])) throw new RuntimeException('final_validation_failed');
$files = [];
foreach (array_merge([['code' => 'base', 'clients' => array_keys($outputs)]], $sites) as $site) {
if (!preg_match('/^[a-zA-Z0-9_.-]+$/D', $site['code'])) throw new RuntimeException('invalid_site_code');
foreach ($site['clients'] as $client) {
if (!isset($outputs[$client])) throw new RuntimeException('unsupported_output_format');
$files[$site['code'].'/'.$client] = $outputs[$client];
}
}
$report['counts'] = ['v2ray' => count($lines), 'clash' => count($proxies), 'singbox' => count($singbox)];
$report['native_rejected'] = $validator->rejected;
$report['native_checks'] = $validator->checks;
return ['files' => $files, 'report' => $report];
}
private function label(string $name, string $id): string
{
$name = preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?? 'Node';
return mb_substr($name, 0, 80).' ['.substr($id, 0, 12).']';
}
}

View File

@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Services\Freenode;
use InvalidArgumentException;
/** URI input is split before percent decoding; the original connection string is retained. */
final class Codec
{
public static function decode64(string $text): string
{
$text = preg_replace('/\s+/', '', $text);
$text = strtr($text, '-_', '+/');
$result = base64_decode(str_pad($text, (int) (ceil(strlen($text) / 4) * 4), '='), true);
if ($result === false) {
throw new InvalidArgumentException('invalid_base64');
}
return $result;
}
public static function parse(string $line): array
{
$line = trim(preg_replace('/^\xEF\xBB\xBF/', '', trim($line)));
if (!preg_match('~^([a-zA-Z0-9]+)://(.+)$~s', $line, $m)) {
throw new InvalidArgumentException('invalid_scheme');
}
$type = strtolower($m[1]);
$type = ['hy2' => 'hysteria2', 'socks5' => 'socks'][$type] ?? $type;
if (!in_array($type, ['vmess', 'vless', 'trojan', 'ss', 'socks', 'http', 'https', 'hysteria2', 'hysteria', 'tuic', 'anytls'], true)) {
throw new InvalidArgumentException('unsupported_protocol');
}
if ($type === 'vmess') {
$config = json_decode(self::decode64(explode('#', $m[2], 2)[0]), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($config)) {
throw new InvalidArgumentException('invalid_vmess');
}
self::endpoint((string) ($config['add'] ?? ''), (int) ($config['port'] ?? 0));
self::uuid((string) ($config['id'] ?? ''));
$name = (string) ($config['ps'] ?? 'VMess');
$config['ps'] = '';
return self::node($type, $config, $name);
}
[$connection, $name] = array_pad(explode('#', $m[2], 2), 2, '');
if (preg_match('/[\x00-\x20\x7F]/', $connection)) {
throw new InvalidArgumentException('invalid_uri_characters');
}
// Old SS encodes the complete authority; SIP002 encodes only userinfo.
if ($type === 'ss' && !str_contains($connection, '@')) {
$connection = self::decode64($connection);
}
$url = parse_url('proxy://'.$connection);
if ($url === false) {
throw new InvalidArgumentException('invalid_authority');
}
$host = trim((string) ($url['host'] ?? ''), '[]');
$port = (int) ($url['port'] ?? 0);
self::endpoint($host, $port);
$query = [];
foreach (explode('&', $url['query'] ?? '') as $part) {
if ($part === '') continue;
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
$query[rawurldecode($key)] = rawurldecode($value);
}
$user = rawurldecode($url['user'] ?? '');
$password = rawurldecode($url['pass'] ?? '');
if ($type === 'ss') {
if (!isset($url['pass'])) {
[$user, $password] = array_pad(explode(':', self::decode64($user), 2), 2, '');
}
if ($user === '' || $password === '') throw new InvalidArgumentException('invalid_ss_auth');
}
if (in_array($type, ['vless', 'tuic'], true)) self::uuid($user);
if (in_array($type, ['trojan', 'anytls', 'hysteria2'], true) && $user === '') {
throw new InvalidArgumentException('missing_auth');
}
// Rebuild only authority. Keep query bytes intact: do not decode the whole URI.
$authority = ($url['user'] ?? '').(isset($url['pass']) ? ':'.$url['pass'] : '');
$authority = ($authority !== '' ? $authority.'@' : '').(str_contains($host, ':') ? '['.$host.']' : $host).':'.$port;
$connection = $authority.($url['path'] ?? '').(isset($url['query']) ? '?'.$url['query'] : '');
return self::node($type, ['host' => $host, 'port' => $port, 'user' => $user, 'password' => $password, 'query' => $query, 'connection' => $connection], rawurldecode($name));
}
private static function node(string $type, array $data, string $name): array
{
$identity = $data;
// Identity is semantic and independent of name, source and URI query ordering.
unset($identity['connection']);
$sort = function (array $a) use (&$sort): array {
if (!array_is_list($a)) ksort($a);
foreach ($a as &$v) if (is_array($v)) $v = $sort($v);
return $a;
};
return ['id' => hash('sha256', json_encode([$type, $sort($identity)], JSON_THROW_ON_ERROR)), 'protocol' => $type, 'data' => $data, 'name' => $name ?: strtoupper($type), 'sources' => []];
}
public static function endpoint(string $host, int $port): void
{
if ($host === '' || preg_match('/[\x00-\x20\x7F\/\[\]@?#]/', $host) || $port < 1 || $port > 65535) {
throw new InvalidArgumentException('invalid_endpoint');
}
}
private static function uuid(string $uuid): void
{
if (!preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i', $uuid)) throw new InvalidArgumentException('invalid_uuid');
}
public static function uri(array $node, string $name): string
{
if ($node['protocol'] === 'vmess') {
$config = $node['data'];
$config['ps'] = $name;
return 'vmess://'.base64_encode(json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
}
return $node['protocol'].'://'.$node['data']['connection'].'#'.rawurlencode($name);
}
public static function singbox(array $node, string $name): array
{
$type = $node['protocol'];
$d = $node['data'];
if ($type === 'vmess') {
$transport = strtolower($d['net'] ?? 'tcp');
$q = ['type' => $transport, 'path' => $d['path'] ?? '', 'host' => $d['host'] ?? '', 'serviceName' => $d['path'] ?? '', 'sni' => $d['sni'] ?? $d['host'] ?? '', 'security' => $d['tls'] ?? '', 'alpn' => $d['alpn'] ?? '', 'fp' => $d['fp'] ?? ''];
if (($d['type'] ?? '') === 'http' && $transport === 'tcp') throw new InvalidArgumentException('unsupported_tcp_http');
$out = ['type' => 'vmess', 'tag' => $name, 'server' => $d['add'], 'server_port' => (int) $d['port'], 'uuid' => $d['id'], 'security' => $d['scy'] ?? 'auto', 'alter_id' => (int) ($d['aid'] ?? 0)];
} else {
$q = $d['query'];
$out = ['type' => ['ss' => 'shadowsocks', 'https' => 'http'][$type] ?? $type, 'tag' => $name, 'server' => $d['host'], 'server_port' => $d['port']];
switch ($type) {
case 'vless':
$out['uuid'] = $d['user'];
if (!empty($q['flow'])) $out['flow'] = $q['flow'];
if (!empty($q['packetEncoding']) && $q['packetEncoding'] !== 'none') $out['packet_encoding'] = $q['packetEncoding'];
break;
case 'trojan': case 'anytls': case 'hysteria2':
$out['password'] = $d['user'];
break;
case 'ss':
$out['method'] = $d['user']; $out['password'] = $d['password'];
if (!empty($q['plugin'])) {
[$plugin, $options] = array_pad(explode(';', $q['plugin'], 2), 2, '');
$out['plugin'] = $plugin; $out['plugin_opts'] = $options;
}
break;
case 'socks': case 'http': case 'https':
if ($d['user'] !== '') $out['username'] = $d['user'];
if ($d['password'] !== '') $out['password'] = $d['password'];
break;
case 'tuic':
$out['uuid'] = $d['user']; $out['password'] = $d['password'];
foreach (['congestion_control', 'udp_relay_mode'] as $k) if (isset($q[$k])) $out[$k] = $q[$k];
break;
default: throw new InvalidArgumentException('unsupported_singbox_protocol');
}
}
$net = strtolower($q['type'] ?? 'tcp');
if (in_array($type, ['vless', 'vmess', 'trojan'], true)) {
if (!in_array($net, ['tcp', 'raw', 'none', '', 'ws', 'grpc', 'http', 'httpupgrade'], true) || !empty($q['headerType']) && $q['headerType'] !== 'none') {
throw new InvalidArgumentException('unsupported_transport');
}
if (in_array($net, ['ws', 'http', 'httpupgrade'], true)) {
$out['transport'] = ['type' => $net, 'path' => $q['path'] ?? '/'];
if (!empty($q['host'])) {
if ($net === 'http') $out['transport']['host'] = explode(',', $q['host']);
else $out['transport']['headers'] = ['Host' => $q['host']];
}
} elseif ($net === 'grpc') {
$out['transport'] = ['type' => 'grpc', 'service_name' => $q['serviceName'] ?? ''];
}
}
if (in_array($type, ['trojan', 'anytls', 'hysteria2', 'tuic', 'https'], true) || in_array($q['security'] ?? '', ['tls', 'reality'], true)) {
$tls = ['enabled' => true];
if (!empty($q['sni'] ?? $q['peer'] ?? '')) $tls['server_name'] = $q['sni'] ?? $q['peer'];
if (!empty($q['alpn'])) $tls['alpn'] = explode(',', $q['alpn']);
if (isset($q['insecure']) || isset($q['allowInsecure'])) $tls['insecure'] = in_array(strtolower($q['insecure'] ?? $q['allowInsecure']), ['1', 'true', 'yes'], true);
if (!empty($q['fp'])) $tls['utls'] = ['enabled' => true, 'fingerprint' => $q['fp']];
if (($q['security'] ?? '') === 'reality') {
if (empty($q['pbk'])) throw new InvalidArgumentException('missing_reality_key');
$tls['reality'] = ['enabled' => true, 'public_key' => $q['pbk'], 'short_id' => $q['sid'] ?? ''];
}
$out['tls'] = $tls;
}
if ($type === 'hysteria2' && !empty($q['obfs'])) $out['obfs'] = ['type' => $q['obfs'], 'password' => $q['obfs-password'] ?? ''];
return $out;
}
}

View File

@ -0,0 +1,72 @@
<?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;
public function __construct(private string $singbox, private string $mihomo, private ?array $clashTemplate = null)
{
$this->dir = sys_get_temp_dir().'/freenode-check-'.bin2hex(random_bytes(8));
mkdir($this->dir, 0700, true);
}
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);
$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(45);
try {
$process->run();
$this->checks++;
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 || $this->check($format, $render($nodes))) return $nodes;
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);
// A final whole-file check also catches cross-node constraints.
if (!$this->check($format, $render($good))) throw new RuntimeException('combined_config_invalid:'.$format);
return $good;
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services\Freenode;
use App\Models\CrawlFreenode;
use App\Models\SiteMap;
use RuntimeException;
final class Pipeline
{
public static function enabled(): bool { return config('freenode.engine', 'legacy') === 'v2'; }
public static function store(): ReleaseStore
{
return new ReleaseStore(storage_path('app/freenode-v2'), public_path('freenode/merge'));
}
public static function prepare(): array
{
$sources = [];
$cycle = strtotime(date('Y-m-d').' '.sprintf('%02d', intdiv((int) date('H'), 6) * 6).':00:00');
foreach (CrawlFreenode::where('status', 1)->get() as $source) {
if (!preg_match('/^[a-zA-Z0-9_-]+$/D', $source->name)) throw new RuntimeException('invalid_source_name');
$row = ['name' => $source->name];
foreach (json_decode($source->file_real ?? '[]', true, 512, JSON_THROW_ON_ERROR) as $format => $file) {
if (!in_array($format, ['v2ray', 'clash'], true)) continue;
if (!preg_match('/^'.date('Ymd').'-(v2ray\.txt|clash\.yaml)$/D', $file)) throw new RuntimeException('stale_source_date:'.$source->name);
$path = public_path('freenode/from/'.$source->name.'/'.$file);
if (!is_file($path) || filemtime($path) < $cycle) throw new RuntimeException('stale_source_cycle:'.$source->name);
$row[$format] = file_get_contents($path);
}
if (!isset($row['v2ray'], $row['clash'])) throw new RuntimeException('incomplete_source:'.$source->name);
$sources[] = $row;
}
$sites = [];
foreach (SiteMap::whereIn('group_code', ['fn_a', 'fn_b'])->where('status', 1)->get() as $site) {
$config = json_decode($site->config ?? '[]', true, 512, JSON_THROW_ON_ERROR);
$sites[] = ['code' => $site->code, 'clients' => $config['freenode_client'] ?? []];
}
if (!$sources || !$sites) throw new RuntimeException('no_enabled_sources_or_sites');
$sites[] = ['code' => '_admin', 'clients' => ['v2ray', 'clash', 'singbox']];
$clashTemplate = \Symfony\Component\Yaml\Yaml::parseFile(public_path('freenode/template/clash.txt'));
$validator = new NativeValidator(config('freenode.singbox_binary'), config('freenode.mihomo_binary'), $clashTemplate);
$result = (new Builder())->build($sources, $sites, $validator);
return self::store()->stage($result, date('Y-m-d'), (float) config('freenode.minimum_ratio', 0.5));
}
}

View File

@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Services\Freenode;
use RuntimeException;
final class ReleaseStore
{
public function __construct(private string $root, private string $publicRoot)
{
if (!is_dir($root)) mkdir($root, 0700, true);
}
public function locked(callable $callback, bool $exclusive = true): mixed
{
$lock = fopen($this->root.'/publish.lock', 'c');
if (!$lock || !flock($lock, $exclusive ? LOCK_EX : LOCK_SH)) throw new RuntimeException('release_lock_failed');
try { return $callback(); } finally { flock($lock, LOCK_UN); fclose($lock); }
}
public function stage(array $result, string $date, float $minimumRatio = 0.5): array
{
return $this->locked(function () use ($result, $date, $minimumRatio) {
$previous = $this->current();
foreach ($result['report']['counts'] as $format => $count) {
$baseline = $previous['counts'][$format] ?? $this->legacyCount($format, $date);
if ($count < 1 || $baseline > 0 && $count < $baseline * $minimumRatio) throw new RuntimeException('count_drop_guard:'.$format);
}
$id = gmdate('YmdHis').'-'.bin2hex(random_bytes(6));
$dir = $this->root.'/versions/'.$id;
mkdir($dir, 0700, true);
$manifest = ['id' => $id, 'date' => $date, 'created_at' => time(), 'validated' => true, 'counts' => $result['report']['counts'], 'files' => []];
foreach ($result['files'] as $key => $content) {
$this->safeKey($key);
$path = $key.'/'.$date.'.txt';
$this->atomic($dir.'/'.$path, $content, 0600);
$manifest['files'][$key] = ['path' => $path, 'sha256' => hash('sha256', $content)];
}
$this->atomic($dir.'/report.json', json_encode($result['report'], JSON_THROW_ON_ERROR), 0600);
$this->atomic($dir.'/manifest.json', json_encode($manifest, JSON_THROW_ON_ERROR), 0600);
$this->atomic($this->root.'/pending.json', json_encode($manifest, JSON_THROW_ON_ERROR), 0600);
return $manifest;
});
}
public function publishPrepared(): array
{
return $this->locked(function () {
$m = $this->readJson($this->root.'/pending.json');
if (!$m || $m['date'] !== date('Y-m-d') || time() - $m['created_at'] > 1800) throw new RuntimeException('no_fresh_prepared_release');
return $this->activateUnlocked($m['id']);
});
}
public function activate(string $id): array
{
return $this->locked(fn () => $this->activateUnlocked($id));
}
private function activateUnlocked(string $id): array
{
if (!preg_match('/^[a-zA-Z0-9-]+$/D', $id)) throw new RuntimeException('invalid_release_id');
$manifest = $this->readJson($this->root.'/versions/'.$id.'/manifest.json');
if (!$manifest) throw new RuntimeException('release_not_found');
$contents = $this->contents($manifest);
$previous = $this->current();
// Capture the actual compatibility files before the first v2 publication.
if (!$previous) $previous = $this->snapshotLegacy($manifest);
$backups = [];
try {
foreach ($contents as $key => $content) {
$target = $this->publicRoot.'/'.$key.'/'.$manifest['date'].'.txt';
$backups[$target] = is_file($target) ? file_get_contents($target) : null;
$this->atomic($target, $content, 0644);
}
// Readers of v2 use this manifest and immutable release files under the shared lock.
$this->atomic($this->root.'/current.json', json_encode($manifest, JSON_THROW_ON_ERROR), 0600);
if ($previous && $previous['id'] !== $id) $this->atomic($this->root.'/previous.json', json_encode($previous, JSON_THROW_ON_ERROR), 0600);
} catch (\Throwable $e) {
foreach ($backups as $target => $content) {
if ($content === null) @unlink($target); else $this->atomic($target, $content, 0644);
}
throw $e;
}
return $manifest;
}
public function current(): ?array { return $this->readJson($this->root.'/current.json'); }
public function previous(): ?array { return $this->readJson($this->root.'/previous.json'); }
public function contents(array $manifest): array
{
$result = [];
foreach ($manifest['files'] as $key => $file) {
$this->safeKey($key);
$expected = $key.'/'.$manifest['date'].'.txt';
if ($file['path'] !== $expected) throw new RuntimeException('invalid_release_path');
$path = $this->root.'/versions/'.$manifest['id'].'/'.$expected;
$content = is_file($path) ? file_get_contents($path) : false;
if ($content === false || !hash_equals($file['sha256'], hash('sha256', $content))) throw new RuntimeException('release_integrity_failed');
$result[$key] = $content;
}
return $result;
}
public function readSite(string $site, array $clients): array
{
return $this->locked(function () use ($site, $clients) {
$current = $this->current();
if (!$current || !$current['validated'] || time() - $current['created_at'] > 86400) throw new RuntimeException('no_recent_validated_release');
$all = $this->contents($current); $result = [];
foreach ($clients as $client) {
if (!isset($all[$site.'/'.$client])) throw new RuntimeException('incomplete_site_release');
$result[$client] = $all[$site.'/'.$client];
}
return $result;
}, false);
}
private function snapshotLegacy(array $new): ?array
{
$id = 'legacy-'.gmdate('YmdHis').'-'.bin2hex(random_bytes(4));
$manifest = ['id' => $id, 'date' => $new['date'], 'created_at' => time(), 'validated' => false, 'counts' => [], 'files' => []];
foreach ($new['files'] as $key => $v) {
$path = $key.'/'.$new['date'].'.txt'; $source = $this->publicRoot.'/'.$path;
if (!is_file($source)) continue;
$content = file_get_contents($source);
$this->atomic($this->root.'/versions/'.$id.'/'.$path, $content, 0600);
$manifest['files'][$key] = ['path' => $path, 'sha256' => hash('sha256', $content)];
}
if (!$manifest['files']) return null;
$this->atomic($this->root.'/versions/'.$id.'/manifest.json', json_encode($manifest, JSON_THROW_ON_ERROR), 0600);
return $manifest;
}
private function legacyCount(string $format, string $date): int
{
$path = $this->publicRoot.'/base/'.$format.'/'.$date.'.txt';
if (!is_file($path)) return 0;
$s = file_get_contents($path);
try {
return match ($format) {
'v2ray' => count(array_filter(explode("\n", Codec::decode64($s)))),
'singbox' => max(0, count(json_decode($s, true, 512, JSON_THROW_ON_ERROR)['outbounds'] ?? []) - 3),
'clash' => count(\Symfony\Component\Yaml\Yaml::parse($s)['proxies'] ?? []),
};
} catch (\Throwable) { throw new RuntimeException('invalid_baseline:'.$format); }
}
private function safeKey(string $key): void
{
if (!preg_match('~^[a-zA-Z0-9_.-]+/(v2ray|clash|singbox)$~D', $key) || str_contains($key, '..')) throw new RuntimeException('invalid_artifact_key');
}
private function readJson(string $path): ?array
{
return is_file($path) ? json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR) : null;
}
private function atomic(string $path, string $content, int $mode): void
{
if (!is_dir(dirname($path))) mkdir(dirname($path), $mode === 0600 ? 0700 : 0755, true);
$temp = tempnam(dirname($path), '.freenode-');
try {
if (file_put_contents($temp, $content) !== strlen($content)) throw new RuntimeException('artifact_write_failed');
chmod($temp, $mode);
if (!rename($temp, $path)) throw new RuntimeException('artifact_rename_failed');
} finally { if (is_file($temp)) unlink($temp); }
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services\Freenode;
use Symfony\Component\Yaml\Yaml;
final class Renderer
{
public static function singbox(array $nodes): string
{
$tags = array_column($nodes, 'tag');
$config = [
'log' => ['level' => 'warn'],
'dns' => ['servers' => [['type' => 'local', 'tag' => 'local']], 'final' => 'local'],
'inbounds' => [
['type' => 'tun', 'tag' => 'tun-in', 'address' => ['172.19.0.1/30'], 'auto_route' => true, 'strict_route' => true],
['type' => 'mixed', 'tag' => 'mixed-in', 'listen' => '127.0.0.1', 'listen_port' => 7891],
],
'outbounds' => array_merge([
['type' => 'selector', 'tag' => 'proxy', 'outbounds' => $tags ?: ['direct']],
['type' => 'direct', 'tag' => 'direct'],
], array_values($nodes)),
'route' => ['rules' => [
['action' => 'sniff'],
['protocol' => 'dns', 'action' => 'hijack-dns'],
['ip_is_private' => true, 'action' => 'route', 'outbound' => 'direct'],
['rule_set' => ['geoip-cn', 'geosite-cn'], 'action' => 'route', 'outbound' => 'direct'],
], 'rule_set' => [
['tag' => 'geoip-cn', 'type' => 'remote', 'format' => 'binary', 'url' => 'https://fastly.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-cn.srs', 'download_detour' => 'direct'],
['tag' => 'geosite-cn', 'type' => 'remote', 'format' => 'binary', 'url' => 'https://fastly.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-cn.srs', 'download_detour' => 'direct'],
], 'final' => 'proxy', 'default_domain_resolver' => 'local', 'auto_detect_interface' => true],
];
return json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
public static function clash(array $nodes, ?array $template = null): string
{
if ($template !== null) {
$template['proxies'] = array_values($nodes);
$names = array_column($nodes, 'name');
foreach ($template['proxy-groups'] as &$group) {
if (!in_array($group['name'], ['🍃 应用净化', '🛑 全球拦截', '🎯 全球直连'], true)) {
$group['proxies'] = array_values(array_unique(array_merge($group['proxies'] ?? [], $names)));
if (!$group['proxies']) $group['proxies'] = ['DIRECT'];
}
}
unset($group);
return Yaml::dump($template, 12, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
}
return Yaml::dump([
'mixed-port' => 7890, 'allow-lan' => false, 'mode' => 'rule', 'log-level' => 'warning',
'proxies' => array_values($nodes),
'proxy-groups' => [['name' => 'PROXY', 'type' => 'select', 'proxies' => array_column($nodes, 'name') ?: ['DIRECT']]],
'rules' => ['IP-CIDR,10.0.0.0/8,DIRECT,no-resolve', 'IP-CIDR,172.16.0.0/12,DIRECT,no-resolve', 'IP-CIDR,192.168.0.0/16,DIRECT,no-resolve', 'MATCH,PROXY'],
], 12, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
}
}

View File

@ -2,6 +2,11 @@
return [
'engine' => env('FREENODE_ENGINE', 'legacy'),
'minimum_ratio' => (float) env('FREENODE_MINIMUM_RATIO', 0.5),
'singbox_binary' => env('FREENODE_SINGBOX_BINARY', '/opt/master01-freenode/bin/sing-box'),
'mihomo_binary' => env('FREENODE_MIHOMO_BINARY', '/opt/master01-freenode/bin/mihomo'),
'speed_min' => (int) env('freenode_speed_min', 0),
'speed_max' => (int) env('freenode_speed_max', 0),
'count_min' => (int) env('freenode_count_min', 0),

View File

@ -0,0 +1,161 @@
# Freenode:来源、归属与分发
## 核实范围
2026-10-18(Asia/Shanghai;数据库首轮查询时间 17:38:50),经白色机 SSH 跳板只读核查 master-01 的 `/www/wwwroot/master.diaodu.us`:线上代码、数据库只读事务与原始文件统计。未访问下游站点,未测试节点连接,未运行采集/发布,未更改生产代码、配置或业务数据。统计是当次快照,不代表实时可用数。
## 分层含义
| 层次 | 位置 | 含义 |
| --- | --- | --- |
| 采集来源 | `crawl_freenode.name` | 来源标签;不证明节点运营所有权 |
| 采集适配 | `crawl_freenode.group` | 决定 `FreenodeCrawl::getFile__{group}` 的调用,与下游站点组不同 |
| 启用状态 | `crawl_freenode.status` | 采集及入池只读取 status=1 的来源 |
| 入池来源 | `freenode_pool.content[格式][hash].fromName` | 去重后保留的单一来源标签,不是完整多来源关系 |
| 协议/格式 | `protocolType` 与 content 的 v2ray/clash/singbox 键 | v2ray 在这里是 URI 订阅格式,里面包含多种协议;singbox 由 URI 节点转换 |
| 下游站点组 | `site_map.group_code` | fn_a、fn_b 为两套下游发布流程,nodehub 为集中接收方 |
| 文件版本 | `public/freenode/merge/` | base、各站版本、_admin 等;目录存在不证明仍在发布 |
## 采集来源快照
| name | group | status | 最近文件日期 | 入口主机 |
| --- | --- | --- | --- | --- |
| ripaojiedian | ripaojiedian | 0 | 2026-07-06 | raw.githubusercontent.com |
| v2rayshare | v2rayshare | 0 | 2026-07-06 | static.v2rayshare.net |
| shuaidaoya | common | 1 | 2026-10-18 | raw.githubusercontent.com |
| daily_free_vpn | common | 1 | 2026-10-18 | raw.githubusercontent.com |
四条来源均登记 v2ray、clash 两种输入。两个启用来源的当日原始文件修改时间均为 12:03:06 +08:00;shuaidaoya 原始条目为 URI 10、Clash 10,daily_free_vpn 为 URI 1916、Clash 1661。URI 数量按非空行计,不等于有效或去重节点数量。
## 首轮 17:38 节点池快照
| 格式 | 入池条目 | shuaidaoya 标签 | daily_free_vpn 标签 |
| --- | --- | --- | --- |
| v2ray | 1769 | 10 | 1759 |
| singbox | 1699 | 2 | 1697 |
| clash | 1670 | 9 | 1661 |
三个格式不能相加当作独立节点数;跨格式统一身份尚未实现。URI 池协议计数:vless 948、trojan 244、hysteria2 220、ss 216、vmess 98、socks 21、anytls 13、hysteria 5、tuic 2,另有 2 条协议名带 BOM 的 `ss`。尚未测试这些条目有效性。
线上 `FreenodePool::save()` 在每个格式内用配置 hash 作数组键,移除显示名称后去重;后遇到的相同键覆盖前值,包括 fromName。来源查询未显式排序,因此不应把当前保留标签理解为来源优先级。跨来源覆盖风险由代码确认;本次未逐项计算覆盖数量。
线上 Clash 入池明确跳过 anytls;URI 与 singbox 转换覆盖也不同。2026-10-17、18 两天的 content_custom/content_v2ray/content_clash 旧列均为空,现行主链读写 content;旧 save_custom 方法与管理字段仍存在,不能据此认定正在运行。
## 自有分支与宣传条目
代码存在 `_my` 自有来源适配及合并优先分支,但本次线上四条来源记录没有 `_my`,最近两日池的来源统计也没有它;不能认定自有节点正在接入。
`FreenodeMerge::save()` 复制一个实际节点生成站点、Telegram、日期三个宣传占位条目,baseToSite 再替换文案。最终订阅中的显示名称和条目数不宜用来识别独立节点或统计来源质量。`_admin` 是输出/检查目录标签,不能等同于 `_my` 自有来源。
## 下游登记与代码路径
| group_code | code_short | code | 配置格式 |
| --- | --- | --- | --- |
| fn_a | cvf | clashv2rayfree.com | v2ray、clash |
| fn_a | vcf | v2rayclashfree.com | v2ray、clash |
| fn_b | vfn | v2rayfreenode.com | v2ray、singbox、clash |
| fn_b | cfn | clashfreenode.com | v2ray、singbox、clash |
| nodehub | nh | nodehub | v2ray、clash |
上述五条 site_map 的 status 均为 1。nodehub 的配置格式与实际推送范围应分开看:`FreenodeSync::nodehub()` 按各来源站点的 freenode_client 构造数据,所以 fn_b 可推送 singbox,不只取 nodehub 自身的配置列表。
每六小时的代码调度依次采集、入池、生成 base、生成 fn_a/fn_b 站点版、推送 nodehub;另有每三小时 fn_a 旧直推,以及每日 11:03 fn_b 文章和 Telegram 发布。这里核实的是代码条件,没有把它等同为所有定时任务和下游接收已验收。当前同步查询按 group_code 筛选,未附加 status 筛选;停用站点不能仅凭 status 字段推断分发已停止。
merge 下仍有旧命名目录,不能据目录名推断当前站点归属。fn_a/fn_b 是源码中的路由组,不据此消解 GemDocs 关于各站源码项目归属的历史待核实事项。
## 测试点设计约束(尚未实施)
- 从启用来源原始数据保留完整来源集合,再规范化配置生成稳定节点 ID;只从现有池读取会失去被覆盖的来源关联。
- 连接测试按配置去重,一次结果关联多个来源;另记格式转换失败、协议不支持,不能算作节点不可用。
- 排除生成阶段复制的宣传条目,保留原始协议名清理前后的证据,处理 BOM。
- 各站是共享池的输出版本,不需要按四个站重复测试;结果绑定采集批次、配置版本及测试时间。
- 白色机单点可用性不代表国内可用性;本次未部署测试服务或改变分发策略。
## 导入/更新失败调查与重构边界
2026-10-18 用户反馈主要表现为“导入或更新失败”。本轮经白色机只读解析 master01 当日实际产物,未请求下游订阅 URL,未执行客户端导入或节点连接。下述产物更新时间为 UTC 10:09/10:12,即北京时间 18:09/18:12,晚于上文首轮池统计,不应直接将两轮数量比较为转换损失。
### 线上产物确认
| 检查项 | 现场结果 | 判断边界 |
| --- | --- | --- |
| 四个站点 URI 订阅 | 每份 2021 行;外层 Base64 可解码;84 条标准 vmess 前缀后的内容是直接 JSON,没有内层 Base64;2 条协议头带 BOM | 格式缺陷已确认;具体客户端是整份拒绝还是跳过坏条目未实测 |
| URI 的其他异常计数 | 每份 19 行多个原始 `#`,4 行在第一个 `#` 前有空格 | 可疑编码/输入异常,未逐条断定不可用,也未把计数直接相加为独立坏节点数 |
| fn_b 两站 Sing-box | JSON 可解析;1947 个 outbounds(含模板和宣传项);无重复 tag;每份有 1 个不在 1—65535 范围的 server_port | JSON 合法不等于内核配置合法;无效端口已确认 |
| fn_b Sing-box 模板 | 含 inet4_address、block outbound、旧 DNS 格式及旧 inbound 字段 | 有官方文档明确的版本兼容问题,见下文 |
| 四站 Clash | YAML 可解析;每份 1875 个 proxies;无重复 name、无未定义的显式分组引用 | 未做 Mihomo/具体客户端原生配置校验,不能声称没有其他字段/协议兼容问题 |
| fn_a Sing-box 文件 | 当日两站均无文件 | 与当前站点未配置该格式相符,不算生成失败 |
本地与线上 SHA-256 对比:FreenodeCrawl、FreenodePool、FreenodeMerge、ServiceFnPoolV2rayTo、FreenodeController 和 Sing-box 模板均一致;Clash 模板不同。本地代码复现的上述服务逻辑可对应本次线上版本,不能把本地 Clash 模板当作线上原件。
### 已确认的问题与对应位置
1. **VMess 输出漏掉节点级 Base64。** `FreenodeMerge::save()` 将配置数组 JSON 编码后直接拼在 vmess:// 后,外层订阅 Base64 不能替代每条 VMess 链接内部的 Base64。线上 84 条已出现此形态。输出名称、宣传替换也在字符串和 JSON 之间混用,不能保证各协议语义一致。标准见 [v2rayN VMess 分享说明](https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link)。
2. **BOM 是本项目主动写入的。** `FreenodeCrawl::save_utf8_file()` 无条件在文件开头添加 BOM。Pool 的 URI 解析未先清理,导致 BOM 成为协议名的一部分;Sing-box 转换器只在后续局部移除协议头 BOM,不能修复此前已绕过的 VMess 专门解析分支。
3. **URI 先整串解码,再拆结构,破坏参数边界。** Pool 使用 urldecode,转换器再 rawurldecode,随后 parse_str 或继续解码字段。编码的 &、+、#、@ 等可能被当作结构字符,查询路径、密码或公钥可能发生变化;编码层应由各字段所属语法负责,不能重复整串解码。SS 的 Base64URL、插件等还需要按 [SIP002](https://shadowsocks.org/doc/sip002.html) 明确处理。
4. **地址和协议参数解析不完整。** 多个 mod_to_* 用首个冒号拆 host:port,无法解析方括号 IPv6。VMess 转换使用 host/server 构造 TLS server_name,没有保留单独的 sni。VLESS 未处理的传输类型可能被默认成无 transport,而不是明确拒绝;SS 插件参数也未建模。
5. **客户端配置没有版本边界。** 同一 Sing-box 全量模板用于全部客户端。官方列明 inet4_address 等旧字段在 1.12.0 移除,block 等旧 special outbounds 及部分旧 inbound 字段在 1.13.0 移除,旧 DNS 格式在 1.14.0 移除。不能把“Hiddify/Sing-box”两个产品名称视为同一配置版本。依据:[官方废弃清单](https://sing-box.sagernet.org/deprecated/)、[迁移指南](https://sing-box.sagernet.org/migration/)。这里是文档支持的兼容判断,尚未在用户具体客户端版本导入验证。
6. **旧直出路由残留固定测试日期。** `FreenodeController::sub()` 将日期无条件写成 2025-12-22,缺文件最多回退 30 天后返回 false,调用者仍直接 file_get_contents。该缺陷本地/线上代码一致,但主要现行链接由 FreenodeHelperService 指向 Nodehub;未核实用户失败的链接是否走此旧路由,不能归因所有更新失败于它。
### 无生产副作用的本地复现
直接调用现行 ServiceFnPoolV2rayTo::singbox_row,使用 example.invalid、文档 IPv6 地址与虚构 UUID,不启动 Laravel、不访问数据库或网络:
| 输入语义 | 预期 | 实际 |
| --- | --- | --- |
| VLESS 地址 [2001:db8::1]:443 | server=2001:db8::1,port=443 | server=[2001,port=0 |
| WS path=%2Fa%26b | /a&b | /a |
| WS path=%2Fa%2Bb | /a+b | /a b |
| VLESS type=xhttp | 明确支持映射或明确拒绝 | 无 transport,继续返回节点 |
| VMess host=cdn.example.invalid、sni=tls.example.invalid | TLS 使用单独 sni | TLS 使用 host |
这些是确定性逻辑复现,证明转换错误;不等于对全部生产节点影响规模的统计。独立演示也确认重复 urldecode 会把编码的 + 最终变成空格。
### 发布可靠性缺口(代码确认,未伪装成已发生事故)
- 抓取无 HTTP 状态、内容类型/结构、完整性及显式超时校验,Base64 解码也未严格校验;上游错误页可能进入后续处理。
- common 适配里某一种格式失败会返回空列表;旧 file_real 指针可能保留。入池不核对同一采集批次/日期,存在新旧数据混合的路径。
- 按固定分钟分别触发采集、入池、合并、分发,无完成依赖、批次状态及本模块锁。单节点或单来源解析异常可以终止当次阶段;后续分钟仍可能继续消费此前数据。未检查外部调度是否另有锁,不声称全服务器无锁。
- 直接 file_put_contents 覆盖公开文件,无临时产物校验后原子切换;并发读取半成品的风险存在,但本次未观察到半文件。
- 分发在某格式缺失时继续构造剩余 payload,没有完整批次门禁;成功判断仅看响应 JSON 标志,未核实接收方持久化文件校验和与真实订阅交付。
- 现有 NasaTestFnSubfile 主要验页面/文件 HTTP 状态,不解析节点协议或运行配置内核校验。FreenodeWatch 目前只是占位实现。
- 日期/token 参与现行链接生成;用户长期订阅地址的有效期、下游保留策略、缓存及更新响应尚未核实。不能把 master 上文件正确等同于用户更新成功。
### 建议的重构范围与顺序(未实施)
建议重构 Freenode 业务链,保留 Laravel 管理、站点映射、既有文章/Telegram 入口;不因本次问题重写整个 master01 或直接改下游项目。
1. **先建立验收样本与发布门禁。** 冻结当前输入和产物版本,在隔离目录复现问题;固定目标客户端/内核版本,分别做 URI 逐条解析、JSON/YAML 结构校验和内核原生配置校验。格式错误、协议不支持、节点连接失败分开统计。坏节点隔离并有明确原因;空结果/异常骤减不覆盖上一份可用订阅。
2. **规范化模型取代字符串互转。** 来源适配只下载/解包;协议解析层保留原始记录及完整来源关系;建立含 endpoint、认证、TLS/SNI、transport、插件等字段的节点模型。字段级编码、IPv6 与别名统一规范化,稳定节点 ID 不包含显示名称;输出渲染器不再重新解析别的输出格式。
3. **按能力和版本分别渲染。** URI、Mihomo、Sing-box 各自明确可表达的协议与参数,不支持就报告排除原因;必要时输出节点列表供客户端使用独立模板,完整配置与节点列表分开定义,不强行一种完整模板覆盖所有产品。
4. **批次化、原子发布、可回滚。** 每轮有 batch_id、来源时间、模型版本、产物 hash、数量、校验及分发状态;依赖前序成功,锁防重入。先写版本目录并验证,再原子切换发布指针;保留上一份合格产物。各接收方返回批次/hash,失败可幂等重试,是否需要变更 Nodehub 接口须另核实。
5. **新旧并行生成后再切换。** 先不分发新版,比较节点配置、来源覆盖、格式验证和兼容差异;用独立测试订阅做客户端导入/更新验收,再逐站切换。保持原链接契约,涉及链接调整时提供明确兼容/迁移方案。
6. **白色机测试点接入同一模型。** 本地格式与内核校验先完成,白色机承担真实连接检测;结果关联节点配置版本及批次,不用连通测试代替导入有效性检查。
实施前仍需用户实际客户端名称/版本和一条失败订阅的安全定位信息(不写入文档的完整 token);服务端核查该链接的真实路径、接收文件、HTTP 响应及缓存链。当前研究足以确认生成链已有缺陷并划定重构范围,尚未确认用户每次更新失败的具体请求原因。
## V2 实现与运维入口
2026-10-18 开始实施,用户授权重构及可回滚发布。默认 `FREENODE_ENGINE=legacy`,旧实现保留;设置为 `v2` 才切换。本节取代上文建议中的“未实施”状态,生产切换与最终验收以本节后续结果为准。
- `app/Services/Freenode/Codec.php`:URI 结构先拆分再字段解码,BOM 清理、IPv6、VMess 节点级 Base64、独立 SNI、确定性身份及来源关联。未知输出传输明确排除。
- `Builder.php`:从各启用来源的 URI 与 Clash 输入独立建模,分别去重、保留多来源;Sing-box 从 URI 模型渲染,Clash 保留来源节点字段并沿用服务器现有 Clash 策略模板。当前没有声称已统一识别跨 URI/Clash 的同一物理节点。
- `Renderer.php`:Sing-box 采用现代配置(原生验收目标 1.14.3),保留私网/中国规则直连、其余代理的路由意图;不再用复制真实节点的方式制造宣传占位。旧版 Sing-box/Hiddify 的具体兼容性须按产品内核验收,不能承诺所有客户端版本。
- `NativeValidator.php`:Sing-box/Mihomo 每种格式先整份检查;只有失败才二分隔离坏节点,再验整份。原生错误输出不进入日志或报告,报告只记录节点哈希及排除分类。不会启动内核代理服务或接管宿主路由。
- `ReleaseStore.php`:私有版本目录、SHA-256 清单、文件锁、数量骤减保护(默认低于上一版/旧产物的 50% 阻止发布)、逐文件原子替换兼容产物、原子切换当前清单。内部同步读取不可变版本,不能读半成品;兼容公开路径是逐文件原子更新,不能声称整个目录跨请求具备全局事务原子性。
- 首次发布前自动保留将被替换的旧产物版本。默认不迁移/删除数据库或旧代码,回滚代码和订阅产物是两个独立动作。
- 原时刻调度保留:采集后 :06 准备并原生校验,:09 不再重复旧合并,:12 发布准备批次,:15 按原接口分发。来源必须属于本轮六小时采集周期,失败则不覆盖上一份产物;准备批次超过 30 分钟不可发布。超过 24 小时的批次不继续自动推送。
- V2 不更新旧 freenode_pool.content 列;来源/排除/节点关系的权威运行报告在私有版本 report.json,旧后台池展示不能当作 V2 当前产物统计。
- 旧 `/sub/{site}/{client}/{date?}` 在 V2 下改读已校验版本,修复固定历史日期,返回对应 MIME、no-store;历史显式日期当前只支持当前已发布批次日期。主要 Nodehub 链接和接收接口契约未改变。
### 命令与恢复
`php tools/freenode/manage.php status` 只显示引擎、批次/hash 元数据;`prepare` 读取来源并在 `storage/app/freenode-v2/versions/` 生成私有候选,不分发;`publish` 校验哈希和时效后更新产物;`rollback RELEASE_ID` 恢复指定产物,省略 ID 时使用 previous。
官方内核由 `python3 tools/freenode/install_validators.py` 安装到 `/opt/master01-freenode/bin/`,仅支持 Linux amd64,固定官方压缩包 SHA-256:Sing-box 1.14.3、Mihomo 1.19.32。不会改现有代理或系统服务。可通过 FREENODE_SINGBOX_BINARY/FREENODE_MIHOMO_BINARY 指向其他已核验版本。
运行目录需由实际 PHP/调度身份可写。发布前记录旧 Git 提交、并行修改、当前引擎配置及兼容文件哈希。切回旧链路须先停止/避开本轮生成发布,在配置中恢复 `FREENODE_ENGINE=legacy`,清除相应配置缓存,再恢复保留的旧产物;仅回退 Git 不会自动撤回已分发到 Nodehub/各站的文件,必要时按原接口再次同步旧产物。不能声称本地恢复等于全部下游已回滚。
本地验证命令:`TEST_SINGBOX=/path/to/sing-box TEST_MIHOMO=/path/to/mihomo php -d error_reporting=22527 vendor/bin/phpunit tests/Unit/Freenode --do-not-cache-result`。当前 13 项、29 断言通过,含真实原生内核检查、坏节点隔离、参数完整性、数量骤减保护、清单篡改拒绝及旧产物恢复。未安装原生内核时该集成测试会跳过,不能把跳过当作原生验收通过。
尚未完成:服务器内真实输入影子生成、生产切换、线上产物回滚演练、Nodehub 接收及用户客户端更新验收。白色机实际连通测试服务不包含在本次生成链切换中,尚未部署。

View File

@ -0,0 +1,32 @@
<?php
namespace Tests\Unit\Freenode;
use App\Services\Freenode\{Builder, Codec, NativeValidator};
use PHPUnit\Framework\TestCase;
use Symfony\Component\Yaml\Yaml;
final class BuilderTest extends TestCase
{
public function testNativeValidatedBuildPreservesSourcesAndExcludesMalformedNodes(): void
{
$sb = getenv('TEST_SINGBOX'); $mh = getenv('TEST_MIHOMO');
if (!$sb || !$mh) self::markTestSkipped('Set TEST_SINGBOX and TEST_MIHOMO for native validation');
$uri = 'vless://00000000-0000-0000-0000-000000000001@[2001:db8::1]:443?type=ws&path=%2Fa%26b%2Bc&security=tls&sni=example.invalid#name';
$clash = Yaml::dump(['proxies' => [
['name' => 'valid', 'type' => 'ss', 'server' => 'example.invalid', 'port' => 443, 'cipher' => 'aes-128-gcm', 'password' => 'synthetic'],
['name' => 'bad', 'type' => 'ss', 'server' => 'example.invalid', 'port' => 443, 'cipher' => 'invalid-test-cipher', 'password' => 'synthetic'],
]]);
$sources = [
['name' => 'a', 'v2ray' => "\xEF\xBB\xBF".$uri."\nsocks://example.invalid:0#bad", 'clash' => $clash],
['name' => 'b', 'v2ray' => $uri, 'clash' => $clash],
];
$validator = new NativeValidator($sb, $mh);
$result = (new Builder())->build($sources, [['code' => 'test', 'clients' => ['v2ray', 'singbox', 'clash']]], $validator);
self::assertSame(['v2ray' => 1, 'clash' => 1, 'singbox' => 1], $result['report']['counts']);
self::assertSame(['a', 'b'], array_values($result['report']['nodes']['uri'])[0]);
self::assertCount(1, $result['report']['native_rejected']['clash']);
$out = json_decode($result['files']['test/singbox'], true);
self::assertSame('/a&b+c', $out['outbounds'][2]['transport']['path']);
self::assertSame($result['files']['base/clash'], $result['files']['test/clash']);
self::assertSame(Codec::parse($uri)['id'], Codec::parse(Codec::decode64($result['files']['test/v2ray']))['id']);
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace Tests\Unit\Freenode;
use App\Services\Freenode\Codec;
use PHPUnit\Framework\TestCase;
use InvalidArgumentException;
final class CodecTest extends TestCase
{
private const UUID = '00000000-0000-0000-0000-000000000001';
public function testEncodedParametersSurviveRoundtrip(): void
{
$uri = 'vless://'.self::UUID.'@example.invalid:443?type=ws&path=%2Fa%26b%2Bc%23d%3Fq%3D1&sni=tls.example.invalid&security=tls#name';
$node = Codec::parse($uri);
$out = Codec::singbox($node, 'test');
self::assertSame('/a&b+c#d?q=1', $out['transport']['path']);
self::assertSame('tls.example.invalid', $out['tls']['server_name']);
self::assertSame($node['id'], Codec::parse(Codec::uri($node, 'new name'))['id']);
}
public function testIpv6Endpoint(): void
{
$node = Codec::parse('vless://'.self::UUID.'@[2001:db8::1]:443?security=tls#test');
$out = Codec::singbox($node, 'test');
self::assertSame('2001:db8::1', $out['server']);
self::assertSame(443, $out['server_port']);
}
public function testVmessAndBom(): void
{
$config = ['add' => 'example.invalid', 'port' => '443', 'id' => self::UUID, 'net' => 'ws', 'host' => 'cdn.example.invalid', 'sni' => 'tls.example.invalid', 'tls' => 'tls', 'ps' => 'original'];
$node = Codec::parse("\xEF\xBB\xBFvmess://".base64_encode(json_encode($config)));
$uri = Codec::uri($node, '中文 + name');
self::assertSame('中文 + name', json_decode(Codec::decode64(substr($uri, 8)), true)['ps']);
self::assertSame('tls.example.invalid', Codec::singbox($node, 'x')['tls']['server_name']);
}
public function testPasswordCharactersAreNotDecodedTwice(): void
{
$node = Codec::parse('trojan://a%2Bb%40c%23d@example.invalid:443?security=tls#test');
self::assertSame('a+b@c#d', Codec::singbox($node, 'x')['password']);
}
public function testSsBase64UrlAndPlugin(): void
{
$auth = rtrim(strtr(base64_encode('aes-128-gcm:a+b'), '+/', '-_'), '=');
$node = Codec::parse('ss://'.$auth.'@example.invalid:443?plugin=v2ray-plugin%3Btls%3Bhost%3Dexample.invalid#test');
$out = Codec::singbox($node, 'x');
self::assertSame('a+b', $out['password']);
self::assertSame('v2ray-plugin', $out['plugin']);
self::assertSame('tls;host=example.invalid', $out['plugin_opts']);
self::assertSame($node['id'], Codec::parse(Codec::uri($node, 'new'))['id']);
}
public function testQueryOrderAndNamesDoNotChangeIdentity(): void
{
$a = Codec::parse('vless://'.self::UUID.'@example.invalid:443?type=ws&path=%2Fx#first');
$b = Codec::parse('vless://'.self::UUID.'@example.invalid:443?path=%2Fx&type=ws#second');
self::assertSame($a['id'], $b['id']);
}
public function testUnknownTransportIsNotSilentlyConverted(): void
{
$node = Codec::parse('vless://'.self::UUID.'@example.invalid:443?type=xhttp#test');
$this->expectException(InvalidArgumentException::class);
Codec::singbox($node, 'x');
}
public function testInvalidPortIsRejected(): void
{
$this->expectException(InvalidArgumentException::class);
Codec::parse('socks://example.invalid:0#test');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace Tests\Unit\Freenode;
use App\Services\Freenode\ReleaseStore;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ReleaseStoreTest extends TestCase
{
private string $dir;
protected function setUp(): void { $this->dir = sys_get_temp_dir().'/fn-store-test-'.bin2hex(random_bytes(6)); mkdir($this->dir, 0700); }
protected function tearDown(): void {
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $f) { if ($f->isDir()) rmdir($f->getPathname()); else unlink($f->getPathname()); }
rmdir($this->dir);
}
private function fixture(string $value, int $count = 10): array { return ['files' => ['site/v2ray' => $value], 'report' => ['counts' => ['v2ray' => $count]]]; }
public function testRollbackRestoresPreviousReleaseAndPublicFile(): void {
$store = new ReleaseStore($this->dir.'/private', $this->dir.'/public');
$a = $store->stage($this->fixture('first'), date('Y-m-d')); $store->publishPrepared();
$b = $store->stage($this->fixture('second'), date('Y-m-d')); $store->publishPrepared();
self::assertSame(['v2ray' => 'second'], $store->readSite('site', ['v2ray']));
$store->activate($store->previous()['id']);
self::assertSame($a['id'], $store->current()['id']);
self::assertSame('first', file_get_contents($this->dir.'/public/site/v2ray/'.date('Y-m-d').'.txt'));
}
public function testCountDropDoesNotReplaceCurrent(): void {
$store = new ReleaseStore($this->dir.'/private', $this->dir.'/public');
$a = $store->stage($this->fixture('first'), date('Y-m-d')); $store->publishPrepared();
try { $store->stage($this->fixture('empty', 1), date('Y-m-d')); self::fail(); } catch (RuntimeException $e) { self::assertStringContainsString('count_drop_guard', $e->getMessage()); }
self::assertSame($a['id'], $store->current()['id']);
}
public function testTamperedFilesCannotBePublished(): void {
$store = new ReleaseStore($this->dir.'/private', $this->dir.'/public');
$a = $store->stage($this->fixture('first'), date('Y-m-d'));
file_put_contents($this->dir.'/private/versions/'.$a['id'].'/site/v2ray/'.date('Y-m-d').'.txt', 'tampered');
$this->expectException(RuntimeException::class); $store->publishPrepared();
}
public function testFirstPublishCapturesLegacyBytesForRollback(): void {
$public = $this->dir.'/public/site/v2ray'; mkdir($public, 0700, true); file_put_contents($public.'/'.date('Y-m-d').'.txt', 'legacy');
$store = new ReleaseStore($this->dir.'/private', $this->dir.'/public');
$store->stage($this->fixture('new'), date('Y-m-d')); $store->publishPrepared();
$legacy = $store->previous(); self::assertFalse($legacy['validated']);
$store->activate($legacy['id']); self::assertSame('legacy', file_get_contents($public.'/'.date('Y-m-d').'.txt'));
}
}

View File

@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Install pinned Linux amd64 validators only; never start proxy services."""
import gzip
import hashlib
import io
import os
from pathlib import Path
import platform
import tarfile
import urllib.request
ASSETS = [
('sing-box', 'https://github.com/SagerNet/sing-box/releases/download/v1.14.3/sing-box-1.14.3-linux-amd64.tar.gz', 'e2bdf179c15a3652955dc44867e33ca0965c9c9b91b34267dcc5a5d639a5feee'),
('mihomo', 'https://github.com/MetaCubeX/mihomo/releases/download/v1.19.32/mihomo-linux-amd64-v1-v1.19.32.gz', '306f81e723e60ce6b828899a6fe83e1d00e9ecefb2dc8d4d849312a5bc00efdc'),
]
def main():
if platform.system() != 'Linux' or platform.machine() not in ('x86_64', 'amd64'):
raise RuntimeError('This manifest targets Linux amd64 only')
root = Path('/opt/master01-freenode/bin')
root.mkdir(mode=0o755, parents=True, exist_ok=True)
for name, url, expected in ASSETS:
request = urllib.request.Request(url, headers={'User-Agent': 'master01-freenode-validator'})
with urllib.request.urlopen(request, timeout=60) as response:
raw = response.read()
if hashlib.sha256(raw).hexdigest() != expected:
raise RuntimeError('Official archive checksum mismatch: ' + name)
if name == 'sing-box':
with tarfile.open(fileobj=io.BytesIO(raw), mode='r:gz') as archive:
member = next(m for m in archive.getmembers() if m.isfile() and m.name.endswith('/sing-box'))
binary = archive.extractfile(member).read()
else:
binary = gzip.decompress(raw)
temp = root / ('.' + name + '.tmp')
temp.write_bytes(binary)
temp.chmod(0o755)
os.replace(temp, root / name)
print(name + ': archive verified, validator installed', flush=True)
if __name__ == '__main__':
main()

24
tools/freenode/manage.php Normal file
View File

@ -0,0 +1,24 @@
<?php
/** Server-side CLI. prepare only stages; publish/rollback are explicit mutations. */
declare(strict_types=1);
require dirname(__DIR__, 2).'/vendor/autoload.php';
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Services\Freenode\Pipeline;
try {
$action = $argv[1] ?? 'status';
$store = Pipeline::store();
$result = match ($action) {
'prepare' => Pipeline::prepare(),
'publish' => $store->publishPrepared(),
'rollback' => $store->activate($argv[2] ?? ($store->previous()['id'] ?? 'missing')),
'status' => ['engine' => config('freenode.engine'), 'current' => $store->current(), 'previous' => $store->previous()],
default => throw new RuntimeException('unknown_action'),
};
if (isset($result['files'])) $result['files'] = count($result['files']);
echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n";
} catch (Throwable $e) {
// Do not print native-core output, node configurations or traces.
fwrite(STDERR, 'Freenode operation failed: '.get_class($e).' '.substr($e->getMessage(), 0, 160)."\n");
exit(1);
}