diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0dd3eb5a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# fnb 项目维护入口 + +项目工作先读 GemDocs AI版项目总览、维护协议及 `/Users/hellcat/Documents/GemDocs/AI版/10-项目/fnb.md`。线上事务优先经白色机 SSH;网页与订阅 HTTP 访问须符合 GemDocs 的反代规则。 + +免费订阅调查及检测范围见 [CFN_SUBSCRIPTIONS.md](docs/maintenance/CFN_SUBSCRIPTIONS.md)。该记录只覆盖已注明的证据,不代表已部署检测器或完成客户端验收。代码、凭据、业务数据及并行修改按当前任务授权处理。 diff --git a/docs/maintenance/CFN_SUBSCRIPTIONS.md b/docs/maintenance/CFN_SUBSCRIPTIONS.md new file mode 100644 index 00000000..7e6a586b --- /dev/null +++ b/docs/maintenance/CFN_SUBSCRIPTIONS.md @@ -0,0 +1,32 @@ +# cfn 免费订阅发布与检测调查 + +调查日期:2026-10-18。范围:用户授权只读调查;经白色机跳转读取线上文件、配置,并以只读事务查询最近三篇已发布文章;未发起线上 HTTP 请求、节点连通测试、客户端核心加载、安装、定时任务或生产变更。 + +## 当前发布链路 + +- cfn:bgp-jp01,Nginx `/etc/nginx/conf.d/clashfreenode.com.conf` 的根目录为 `/www/wwwroot/clashfreenode-new.com/public`。另有 `/www/wwwroot/clashfreenode.com` 旧目录及旧 `public/sub`;不能将旧目录的文件认作当前发布结果。 +- 当前文章使用 `articles` / `categories` 数据;免费节点分类 `freenode`。最近已发布文章 slug 为 `fn20261018`、`fn20261017`、`fn20261016`,分别包含对应日期的三类订阅链接。 +- 订阅文件由 nodehub 线上版本 nodebus.net 托管:`/cfn/<每日变化目录>/-v2ray.txt`、`-singbox.txt`、`-clash.yaml`。线上磁盘 `/www/wwwroot/nodebus.net/public/cfn/`。不复制每日目录值;每天从已发布文章的最终链接发现,不能仅替换日期。 +- nodehub 的 `routes/web.php` 注册 `POST /api/freenode/file/put`,处理器 `app/Http/Controllers/Api/v1/FreenodeController.php::filePut` 接收 `site_code_short` 与各格式 `raw`,生成按日期变化的目录并写文件。所读处理器检查空内容和写入结果,未执行 YAML/JSON/节点字段/客户端核心校验。本次没有追查上游爬取转换实现,不能据此认定全链路均无校验。 +- cfn `GithubController::getFnToday` 是向配置的 master 站 `/api/v1/freenode/url_feed_today/cfn` 转发请求的代码入口,不是本地订阅文件读取。本次未调用;清单返回的实时性和内容尚未验收。 + +## 线上文件检查结果 + +以下是直接读取文件后的结构解析,不等同于 HTTP 下载或客户端导入验收。 + +| 日期 | Clash proxies | Sing-box outbounds(含3个控制项) | V2Ray非空链接行 | +| --- | ---: | ---: | ---: | +| 2026-10-18 | 1875 | 1947 | 2021 | +| 2026-10-17 | 1318 | 1263 | 1326 | +| 2026-10-16 | 1292 | 1241 | 1308 | + +- 九个文件的 YAML / JSON / 外层 Base64 基础解析成功;Sing-box 文件后缀是 `.txt`,实际内容是 JSON。格式应由声明和内容共同确定。 +- 今日 Clash:基础字段齐全、端口范围检查通过,未发现重复名称、空服务器及策略组内缺失引用;未执行核心兼容检查。 +- 今日 V2Ray:解码后第4、78个非空链接行在协议名前含 U+FEFF BOM;近两天也分别发现2行。这是实际编码异常;是否跳过节点或导致整体失败取决于客户端,未实测。不可把外层Base64解码成功视为订阅全部正确。 +- 今日 Sing-box:`outbounds[6]`(从0计数,type=vmess)包含空 `server` 和 `server_port: 0`,是需要拦截的节点字段异常;未运行核心,未确认客户端实际报错文案。 +- 不同格式节点数量不同;协议覆盖和转换筛选原因尚未追查,不直接把数量差认定为丢节点故障。 +- 线上 PATH 中未发现 mihomo / sing-box / xray;本次不安装。语法检查通过不能替代对应版本核心的配置校验。 + +## 检测目标与后续边界 + +用户要求的是爬取、加工后的最终订阅能否被客户端接受,不要求逐节点连通或测速。后续检测应区分:文章与订阅地址发现、最终文件下载、外层编码与结构、节点字段、客户端核心兼容。错误报告隐藏节点密码、UUID和令牌。核心版本、每日执行时间、通知方式及可用 HTTP 访问路线尚未确定;白色机检测程序尚未实施。 diff --git a/tools/subscription-checker/.gitignore b/tools/subscription-checker/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/tools/subscription-checker/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/tools/subscription-checker/README.md b/tools/subscription-checker/README.md new file mode 100644 index 00000000..21d2cc30 --- /dev/null +++ b/tools/subscription-checker/README.md @@ -0,0 +1,17 @@ +# cfn 手动订阅检测 + +范围:从 cfn 最新已发布文章发现三类最终链接,经白色机 SSH 读取 nodehub 对应文件,验证编码/结构/字段,再串行执行固定版本 Mihomo 与 Sing-box 的配置检查。V2Ray URI 列表仅做静态导入格式检查,不冒充客户端导入成功。不连接节点,不启用 TUN、代理服务、定时器或通知。 + +代码通过 fn_b Git 提交/推送,白色机稀疏检出本目录到 `/opt/cfn-subscription-checker`,只运行明确提交。`install_cores.py` 从官方发布下载 `cores.json` 固定版本并校验官方API公布的SHA256;独立目录 `/opt/cfn-checker-cores`。不覆盖系统核心。依赖系统 Python3/PyYAML、SSH、systemd-run、GNU time。 + +手动入口(本机):`python3 tools/subscription-checker/run_from_mac.py`。脚本仅按需读取既有私有资源,凭据不进参数、Git或报告;通过SSH标准输入传给白色机,源站问密临时文件在退出时清理,SSH主机密钥使用本机已核实记录严格校验。未配置服务器长期认证,后续定时方案另行处理。 + +报告和当次样本:`/var/lib/cfn-subscription-checker/<北京时间戳>/report.json`,目录700、文件600。样本包含公开节点凭据,仅限root私有检查,不提交Git。错误仅记录字段、序号及脱敏核心日志;报告记录源站文件读取状态,HTTP下载因反代入口缺失明确未测。文章不是当天时标记stale,发现缺失/多义链接会失败,不猜路径、不回退旧文件。 + +核心检查使用独立临时systemd服务:PrivateNetwork、NoNewPrivileges、ProtectSystem=strict,只允许写当次核心工作目录;MemoryMax=256M、MemorySwapMax=0、CPUQuota=50%、RuntimeMaxSec=45;校验完退出,不开放监听。若配置需远端规则/Geo数据而离线缺失,报告blocked_dependency,不伪报配置通过。256M为核心进程的限制,主Python解析进程另占内存。 + +返回值:0=服务器文件结构及可执行核心检查通过(仍未HTTP/GUI验收),1=发现异常、检查未完成或获取失败。检查失败是检测结果,不等于部署失败。 + +回滚:本工具无常驻服务,停止当前手动命令即可;确认无人运行后移除独立代码/核心目录,报告按需保留或另行清理。不改源站业务文件、白色机Nginx、路由或现有容器。依赖包未改时不涉及系统回滚。 + +测试:`python3 -m unittest discover -s tools/subscription-checker -v`。 diff --git a/tools/subscription-checker/check.py b/tools/subscription-checker/check.py new file mode 100644 index 00000000..b2ee43f7 --- /dev/null +++ b/tools/subscription-checker/check.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Manual cfn subscription validator. No timers, notifications, or proxy traffic.""" +import argparse, base64, collections, datetime, html, json, os, re, shlex, shutil, subprocess, sys, tempfile +from pathlib import Path +from urllib.parse import urlsplit, unquote +import yaml + +HOSTS = {'article': '45.153.247.44', 'files': '45.142.166.119'} +FORMATS = {'clash': '.yaml', 'singbox': '.txt', 'v2ray': '.txt'} +MAX_BYTES = 20 * 1024 * 1024 + +def command(args, **kw): + return subprocess.run(args, capture_output=True, timeout=kw.pop('timeout', 45), **kw) + +def ssh(host, code, env): + r = command(['ssh', '-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile='+env['CFN_KNOWN_HOSTS'], '-o', 'ConnectTimeout=12', '-o', 'NumberOfPasswordPrompts=1', 'root@'+host, 'python3 -c '+shlex.quote(code)], env=env, timeout=60) + if r.returncode: + raise RuntimeError('source_ssh_failed:'+host+':exit_'+str(r.returncode)) + if len(r.stdout) > MAX_BYTES: raise RuntimeError('source_response_too_large') + return r.stdout + +def collect(root, secrets): + root.mkdir(mode=0o700, parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='auth-', dir=root) as auth: + a = Path(auth); (a/'credentials.json').write_text(json.dumps(secrets)); (a/'known_hosts').write_text(secrets['known_hosts']) + ask = a/'askpass.py' + ask.write_text('#!/usr/bin/python3\nimport os,sys,json\np=" ".join(sys.argv[1:])\nd=json.load(open(os.environ["CFN_AUTH_FILE"]))\nh=next((h for h in d["passwords"] if h in p),None)\nif not h:sys.exit(1)\nprint(d["passwords"][h])\n') + ask.chmod(0o700) + for f in [a/'credentials.json', a/'known_hosts']: f.chmod(0o600) + env = dict(os.environ, SSH_ASKPASS=str(ask), SSH_ASKPASS_REQUIRE='force', DISPLAY='1', CFN_AUTH_FILE=str(a/'credentials.json'), CFN_KNOWN_HOSTS=str(a/'known_hosts')) + php = r'''chdir('/www/wwwroot/clashfreenode-new.com');require 'vendor/autoload.php';Dotenv\Dotenv::createImmutable(getcwd())->load();try{$p=new PDO('mysql:host='.$_ENV['DB_HOST'].';port='.($_ENV['DB_PORT']??3306).';dbname='.$_ENV['DB_DATABASE'].';charset=utf8mb4',$_ENV['DB_USERNAME'],$_ENV['DB_PASSWORD'],[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);$p->exec('SET TRANSACTION READ ONLY');$p->beginTransaction();$r=$p->query("SELECT a.id,a.sSlug,a.sContent,a.updated_at FROM articles a JOIN categories c ON c.id=a.iCategoryId WHERE c.sSlug='freenode' AND a.iStatus=1 ORDER BY a.id DESC LIMIT 1")->fetch(PDO::FETCH_ASSOC);echo json_encode($r);$p->rollBack();}catch(Throwable $e){echo json_encode(['error'=>'article_query_failed']);exit(1);}''' + code = 'import subprocess,sys\nr=subprocess.run(["php","-r",'+repr(php)+'],capture_output=True)\nsys.stdout.buffer.write(r.stdout)\nsys.exit(r.returncode)' + row = json.loads(ssh(HOSTS['article'], code, env)); content = row.pop('sContent') + links = list(dict.fromkeys(html.unescape(x) for x in re.findall(r'''(?:href|data-clipboard-text)=["']([^"']+)["']''', content))) + found = {} + for link in links: + u=urlsplit(link) + if u.scheme != 'https' or u.netloc != 'nodebus.net' or u.query or u.fragment: continue + m=re.fullmatch(r'/cfn/[a-f0-9]{16}/(\d{8})-(v2ray|singbox|clash)(\.txt|\.yaml)',u.path) + if not m or m[3] != FORMATS[m[2]]: continue + if m[2] in found and found[m[2]]['path'] != u.path: raise RuntimeError('ambiguous_subscription_links') + found[m[2]]={'path':u.path,'date':m[1]} + if set(found) != set(FORMATS): raise RuntimeError('missing_or_unrecognized_subscription_links') + article_date = re.fullmatch(r'fn(\d{8})', row['sSlug']) + if not article_date or any(v['date'] != article_date[1] for v in found.values()): raise RuntimeError('article_file_date_mismatch') + for kind,item in found.items(): + code='from pathlib import Path\nimport sys\np=Path('+repr('/www/wwwroot/nodebus.net/public'+item['path'])+')\nr=p.resolve()\nassert r.is_relative_to(Path("/www/wwwroot/nodebus.net/public/cfn"))\nassert 02:text=text.replace(v,'[redacted]') + text=re.sub(r'(?:https?|ss|vmess|vless|trojan)://[^\s"\']+','[redacted-url]',text) + text=re.sub(r'\x1b\[[0-9;]*m','',text) + status='pass' if r.returncode==0 else 'fail' + if r.returncode and any(x in text.lower() for x in ['network is unreachable','network is down','no such host','download','geosite.dat','geoip.dat','country.mmdb']):status='blocked_dependency' + return {'status':status,'exit_code':r.returncode,'peak_rss_kb':int(mem[1]) if mem else None,'message':text[-3500:]} + except subprocess.TimeoutExpired:return {'status':'timeout'} + +def main(): + os.umask(0o077) + a=argparse.ArgumentParser();a.add_argument('--state-dir',default='/var/lib/cfn-subscription-checker');a.add_argument('--binaries',default='/opt/cfn-checker-cores');a.add_argument('--snapshot-dir');a.add_argument('--credentials-stdin',action='store_true');args=a.parse_args() + now=datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8)));root=Path(args.state_dir)/now.strftime('%Y%m%d-%H%M%S-%f');root.mkdir(mode=0o700,parents=True) + report={'checked_at':now.isoformat(),'http_download':'not_tested_missing_registered_proxy','node_connectivity':'not_tested','results':[]} + try: + if args.snapshot_dir: + source=Path(args.snapshot_dir);manifest=json.loads((source/'manifest.json').read_text()) + for k,suffix in FORMATS.items():shutil.copyfile(source/(k+suffix),root/(k+suffix)) + elif args.credentials_stdin:manifest=collect(root,json.load(sys.stdin)) + else:raise RuntimeError('credentials_stdin_or_snapshot_dir_required') + report['source']=manifest;report['freshness']='pass' if manifest['date']==now.strftime('%Y%m%d') else 'stale' + for kind,suffix in FORMATS.items(): + f=root/(kind+suffix);f.chmod(0o600);result,private=validate(kind,f.read_bytes()) + if kind!='v2ray':result['core_check']=core_check(kind,f,root,Path(args.binaries),private) + report['results'].append(result) + failed=report['freshness']!='pass' or any(r['structure_status']=='fail' or r['core_check']['status'] in ['fail','timeout'] for r in report['results']) + incomplete=any(r['core_check']['status'] in ['blocked_dependency','not_tested'] for r in report['results']) + report['status']='fail' if failed else 'incomplete' if incomplete else 'pass_server_files_only' + except Exception as e: + report['status']='acquisition_failed';report['error_type']=type(e).__name__;report['error']=str(e) if isinstance(e,RuntimeError) else 'see source or format stage' + output=root/'report.json';output.write_text(json.dumps(report,ensure_ascii=False,indent=2));print(output);print(json.dumps(report,ensure_ascii=False,indent=2));return 0 if report['status']=='pass_server_files_only' else 1 + +if __name__=='__main__':sys.exit(main()) diff --git a/tools/subscription-checker/cores.json b/tools/subscription-checker/cores.json new file mode 100644 index 00000000..742e053d --- /dev/null +++ b/tools/subscription-checker/cores.json @@ -0,0 +1,4 @@ +{ + "mihomo": {"version":"v1.19.32","url":"https://github.com/MetaCubeX/mihomo/releases/download/v1.19.32/mihomo-linux-amd64-compatible-v1.19.32.gz","sha256":"ba3ce607747a07f948fc35780e108a4a7c7f552a38b9bd4d115f313ebcb89c20"}, + "sing-box": {"version":"v1.14.3","url":"https://github.com/SagerNet/sing-box/releases/download/v1.14.3/sing-box-1.14.3-linux-amd64.tar.gz","sha256":"e2bdf179c15a3652955dc44867e33ca0965c9c9b91b34267dcc5a5d639a5feee"} +} diff --git a/tools/subscription-checker/install_cores.py b/tools/subscription-checker/install_cores.py new file mode 100644 index 00000000..11e41ac7 --- /dev/null +++ b/tools/subscription-checker/install_cores.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +import gzip,hashlib,json,os,tarfile,tempfile,urllib.request,io +from pathlib import Path +os.umask(0o077) +root=Path('/opt/cfn-checker-cores');root.mkdir(exist_ok=True,mode=0o700) +manifest=json.loads(Path(__file__).with_name('cores.json').read_text()) +for name,info in manifest.items(): + req=urllib.request.Request(info['url'],headers={'User-Agent':'cfn-subscription-checker'}) + with urllib.request.urlopen(req,timeout=90) as r: data=r.read(100*1024*1024) + if hashlib.sha256(data).hexdigest()!=info['sha256']:raise RuntimeError('checksum mismatch: '+name) + if name=='mihomo':binary=gzip.decompress(data) + else: + with tarfile.open(fileobj=io.BytesIO(data),mode='r:gz') as t: + members=[m for m in t.getmembers() if m.isfile() and Path(m.name).name=='sing-box'] + if len(members)!=1:raise RuntimeError('unexpected archive') + binary=t.extractfile(members[0]).read() + f=root/(name+'.new');f.write_bytes(binary);f.chmod(0o700);f.replace(root/name) + print(name,info['version'],'SHA256 verified') +(root/'versions.json').write_text(json.dumps(manifest,indent=2)) diff --git a/tools/subscription-checker/run_from_mac.py b/tools/subscription-checker/run_from_mac.py new file mode 100644 index 00000000..7f1c5e1e --- /dev/null +++ b/tools/subscription-checker/run_from_mac.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Manual launcher: secret values go only over SSH stdin, never argv/Git.""" +import json,os,subprocess,sys,tempfile +from pathlib import Path +HOSTS={'104.168.30.40':'VPS-001','45.153.247.44':'VPS-003','45.142.166.119':'VPS-005'} +base=Path('/Users/hellcat/Documents/GemDocs/AI版/30-私有资源/凭据') +passwords={host:(base/(name+'.md')).read_text().strip().split(':',1)[1].strip() for host,name in HOSTS.items()} +known=[] +for host in HOSTS: + r=subprocess.run(['ssh-keygen','-F',host],capture_output=True,text=True,check=True) + known.extend(x for x in r.stdout.splitlines() if not x.startswith('#')) +with tempfile.TemporaryDirectory(prefix='cfn-manual-') as tmp: + os.chmod(tmp,0o700);p=Path(tmp)/'white-password';p.write_text(passwords['104.168.30.40']);p.chmod(0o600) + a=Path(tmp)/'askpass.py';a.write_text('#!/usr/local/bin/python3\nimport os\nfrom pathlib import Path\nprint(Path(os.environ["CFN_WHITE_PASSWORD"]).read_text())\n');a.chmod(0o700) + env=dict(os.environ,SSH_ASKPASS=str(a),SSH_ASKPASS_REQUIRE='force',DISPLAY='1',CFN_WHITE_PASSWORD=str(p)) + payload={'passwords':{h:v for h,v in passwords.items() if h!='104.168.30.40'},'known_hosts':'\n'.join(known)+'\n'} + cmd='python3 /opt/cfn-subscription-checker/tools/subscription-checker/check.py --credentials-stdin' + r=subprocess.run(['ssh','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=15','root@104.168.30.40',cmd],input=json.dumps(payload).encode(),env=env) + sys.exit(r.returncode) diff --git a/tools/subscription-checker/test_check.py b/tools/subscription-checker/test_check.py new file mode 100644 index 00000000..bd7deb06 --- /dev/null +++ b/tools/subscription-checker/test_check.py @@ -0,0 +1,23 @@ +import base64,json,unittest +from check import validate +class ValidationTests(unittest.TestCase): + def codes(self,kind,s):return {x['code'] for x in validate(kind,s.encode())[0]['issues']} + def test_bom_inside_decoded_subscription(self): + s=base64.b64encode('\ufefftrojan://secret@example.com:443#name'.encode()).decode() + self.assertIn('unexpected_BOM',self.codes('v2ray',s)) + def test_vmess_fragment_is_not_part_of_base64(self): + s='vmess://'+base64.b64encode(json.dumps({'add':'example.com','port':443,'id':'test-id'}).encode()).decode()+'#node' + self.assertEqual(self.codes('v2ray',s),set()) + def test_empty_server_and_port_zero_in_valid_json(self): + codes=self.codes('singbox',json.dumps({'outbounds':[{'type':'vmess','tag':'bad','server':'','server_port':0,'uuid':'test'}]})) + self.assertEqual(codes,{'missing_or_empty_field','invalid_port'}) + def test_html_instead_of_subscription(self):self.assertIn('parse_error',self.codes('clash','error')) + def test_missing_group_target(self): + self.assertIn('missing_group_reference',self.codes('clash','proxies: [{name: ok, type: http, server: example.com, port: 80}]\nproxy-groups: [{name: choose, type: select, proxies: [missing]}]')) + def test_empty_list_is_not_success(self):self.assertIn('empty_node_list',self.codes('clash','proxies: []')) + def test_malformed_base64(self):self.assertIn('parse_error',self.codes('v2ray','@@@bad@@@')) + def test_reports_do_not_contain_secrets(self): + r,_=validate('singbox',json.dumps({'outbounds':[{'type':'trojan','tag':'private-name','server':'secret.host','server_port':0,'password':'test-private-password'}]}).encode()) + text=json.dumps(r) + for secret in ['test-private-password','secret.host','private-name']:self.assertNotIn(secret,text) +if __name__=='__main__':unittest.main()