From d331f45b0e817482c5d744123990a14ab1d07d3f Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 16:24:46 +0800 Subject: [PATCH] feat(nodes): exclude small XrayR hosts and use registered credential fields --- AGENTS.md | 3 ++ docs/maintenance/NODE_BATCH_RUNTIME.md | 8 ++- docs/maintenance/NODE_RUNBOOK.md | 1 + docs/maintenance/STATUS.md | 4 ++ scripts/node-onboarding/acceptance.py | 7 +++ scripts/node-onboarding/configure-site.py | 2 + scripts/node-onboarding/site_adapter.py | 24 ++++++++- scripts/node-onboarding/ssh_pool.py | 9 ++++ tests/node-onboarding/test_runtime.py | 62 ++++++++++++++++++++++- 9 files changed, 117 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2552251..e5c017e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,4 +18,7 @@ 这项授权针对当前任务的子助手,不自动新建或分叉普通聊天,也不授权向已有聊天发消息。仅讨论方案、维护脚本/文档或单节点任务不自动触发。若子助手工具不可用、并发名额不足或助手失败,说明具体缺口并恢复/等待;未经用户明确允许,不静默省略角色或跳过验收。当前批次已有对应助手时复用,不重复启动。具体线上变更仍以用户本次接入授权为限;项目规则不启动无人值守接入。 +- 新增候选按[容量筛选](docs/maintenance/NODE_BATCH_RUNTIME.md#候选容量筛选):物理内存小于1GB且已安装XrayR则排除,不以停服务绕过,不追溯停用已接入节点。 +- 接入所需邮箱验证码,按用户2026-10-20长期授权,先用已登记的对应邮箱自行登录并只读取本次验证邮件;凭据仍走GemDocs统一字段入口,不改账号安全设置、不读取无关邮件。只有资料缺失、认证失败或必须人工完成的验证才请求用户协助;账号切换及网站访问边界继续适用。 + 本文件提供维护入口,不扩大当前任务授权,也不启动无人值守自动维护。 diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 0357230..41530e6 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -14,6 +14,12 @@ | `panel-acceptance.php` | 开工前选择有效正式组账号;缺少时创建本批受限非管理员测试用户,记录归属并收尾禁用 | | `acceptance.py` | 动态集合、逐节点结果、原始证据摘要、QA身份/禁用/拒绝/下发排除和时效的完整自动审计 | +## 候选容量筛选 + +用户2026-10-20指定:物理总内存小于1GB且已安装XrayR的服务器,不作为DPL新节点。两条件须同时成立;已装但未运行、inactive或masked同样计算,不能临时停止/卸载旧服务来绕过。采用`/proc/meminfo`的MemTotal乘1024,1GB=1,000,000,000 bytes;不把1GiB误当门槛,不使用MemAvailable、空闲内存或swap。容量或安装证据不明时暂停核查,不按未安装放行。该规则筛选新增候选,不自动迁移、停用已接入节点。 + +预选时先核实;正式适配器每次preflight在准备正式测试账号和stage前再次执行`capacity_gate`,本地600回执绑定请求、字节数、安装状态和采集时间。小内存且已装时拒绝进入接入阶段;不能为了凑批次数忽略。满足本规则只代表未被该条件排除,仍须通过余下资源、原服务、端口与容量检查。 + ## 准备与Git部署 先按CoDocs确认VPS、白色机访问路线、正式组、展示编号及资源冲突。Python执行环境使用`requirements.txt`的Paramiko 5.0.0;配置与执行必须使用同一解释器。安装来源限官方包源/已审核环境,不临时导入任务目录中的未知transport模块。 @@ -56,7 +62,7 @@ Certbot准备不能只检查`command -v`:还须实际运行`certbot --version` 批次备份目录必须独立且固定,不跨实际批次复用旧资源基线。新节点端口、正式客户端端口均查重预留;共享网关客户端还使用同一账本内的进程锁。当前适配器用于独立AnyTLS节点及明确列出的既有服务;不自动把白色机、面板或网关当节点宿主。 -`ssh.json`含`known_hosts`绝对路径及`hosts`映射,key为准确CoDocs VPS ID。每台登记`host`、`port`、`username`、用户已批准的`host_key_sha256`(OpenSSH SHA256格式)、可选`credential_ref`;白色机额外登记`role=white_jump`,ID须与`context.jump_vps`一致。候选IP必须与冻结计划相同。`credential_ref`只引用既定安全存储中的600文件。默认JSON支持password/key_filename/passphrase;既有CoDocs Markdown可设`credential_format=codocs-password`,仅按原文件中唯一的“密码:”字段在内存读取,不能为了适配而复制或改写原凭据正文。内容不得进入计划、日志、argv或Git。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。 +`ssh.json`含`known_hosts`绝对路径及`hosts`映射,key为准确CoDocs VPS ID。每台登记`host`、`port`、`username`、用户已批准的`host_key_sha256`(OpenSSH SHA256格式)、可选`credential_ref`;白色机额外登记`role=white_jump`,ID须与`context.jump_vps`一致。候选IP必须与冻结计划相同。`credential_ref`只引用既定安全存储中的600文件。新计划统一设`credential_format=gemdocs-resource`及准确`credential_resource=VPS-xxx`,通过GemDocs `tools/credentials.py`的`get_field`按登记格式读取password,原样交给SSH组件,不自行剥标签或trim。configure同时固定credentials.py/resources.py摘要;格式或字段异常停止,不回退全文。旧JSON/codocs-password分支仅保留旧冻结版本兼容,不用于新计划;原始凭据不改写、不复制。内容不得进入计划、日志、argv或Git。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。 只从known-hosts文件选择与冻结配置中已批准指纹完全相符的公钥;找不到匹配时在读取/发送凭据前停止。全局known-hosts增加无关记录无需改变本批受信任指纹。不关闭主机密钥检查,不自动TOFU;首次信任先按访问规则核验/批准精确指纹,保存公开known-host条目。会话丢失只标记未知,不自动重发安装/开放命令;下次仍从现场probe恢复。 diff --git a/docs/maintenance/NODE_RUNBOOK.md b/docs/maintenance/NODE_RUNBOOK.md index 39bbaa5..3dab026 100644 --- a/docs/maintenance/NODE_RUNBOOK.md +++ b/docs/maintenance/NODE_RUNBOOK.md @@ -74,6 +74,7 @@ esac | 节点 | OS/架构、内存/磁盘、时间同步、systemd、Git、curl、sha256sum、ss、证书工具,以及命令实际路径 | 先明确缺失工具和安装来源;安装属于接入任务变更,文档整理不安装 | | 软件来源 | 发行版源可下载、包版本与依赖可满足;运维仓库和官方二进制来源/摘要可验证 | 不把 apt 索引存在当可安装;不自动换不明镜像或全量升级 | | 证书 | 已有 renewal 配置、pre/post/deploy hooks及renew_hook别名、未知可执行hook、Certbot 是 apt 还是 Snap、可执行路径、timer/cron 的真实调用参数和运行用户 | 确定新证书隔离方案及可生效调度后再签发;不能仅添加 renewal 文件就视为自动续期完成 | +| 候选内存 | 按[容量筛选](NODE_BATCH_RUNTIME.md#候选容量筛选)核对物理MemTotal及XrayR是否已安装(不限active) | 小于1GB且已装XrayR则排除,未知先核查;不改动旧服务绕过 | | 节点隔离 | 现有 XrayR 等服务的 PID/启动时间、TCP/UDP 监听、防火墙与新端口、旧证书和目录权限 | 冲突先处理;安装脚本只检查 TCP,不能代替全项前检 | | 中转 | 后台/登录账户按 CoDocs 读取,当前规则快照、目标落地、隧道及入口端口、Proxy Protocol 两端设置 | 缺入口先问,不改原规则;创建后回读实际值 | | 网关 | 容器 ID/启动时间/重启计数、CPU/内存、独立 QA 路径和端口、Python3、官方客户端及其摘要 | 补齐验收依赖后再 stage,不边创建业务记录边找工具 | diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index cdc7d31..e81c44c 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -2,6 +2,10 @@ 文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。 +## 2026-10-20 三节点新批次准备 + +新增候选门禁:MemTotal小于1,000,000,000 bytes且已安装XrayR则拒绝,在任何验收账号准备/stage前现场复查;未知不放行。新计划SSH凭据统一按GemDocs准确资源ID读取,值不trim,并固定读取模块摘要。50项Python离线回归通过。已批准sg02/03/04精确SSH指纹,三台实际总内存均8,294,055,936 bytes、XrayR已装、SG出口且24443空闲;候选筛选通过不等于接入完成。生产写入/最终验收待本批继续,本条不得作为已部署证明。 + ## 2026-10-20 验收按影响范围分层(本地实现) 新节点隔离QA完整测试不减;正式开放后保持动态全量订阅/权限/QA清理与原资源基线检查,已评审独立append使用逐节点严格TLS连通检查;指定受影响节点完整联网,共享变化、未知影响或本任务新建正式验收账号全组完整联网。范围绑定计划及报告,旧结果不改。参数和边界唯一入口见[正式适配器](NODE_BATCH_RUNTIME.md#按影响范围验收2026-10-20)。本次46项Python离线回归通过(含新旧集合、范围绑定、分层探针分派、完整正式调用链和新建账号强制完整测试);未连接服务器、未部署新运维代码或进行线上验收。 diff --git a/scripts/node-onboarding/acceptance.py b/scripts/node-onboarding/acceptance.py index 037ef67..b9a907f 100644 --- a/scripts/node-onboarding/acceptance.py +++ b/scripts/node-onboarding/acceptance.py @@ -31,6 +31,13 @@ def snapshot(nodes): return sorted(result, key=lambda n: n['id']) +def candidate_eligibility(total_memory_bytes, xrayr_installed): + """Use physical MemTotal, excluding swap and currently free memory. 1 GB = 10**9 bytes.""" + if type(total_memory_bytes) is not int or total_memory_bytes <= 0 or type(xrayr_installed) is not bool: + raise ValueError('Unknown candidate memory or XrayR installation') + return not (total_memory_bytes < 1_000_000_000 and xrayr_installed) + + def validate_impact(impact): if impact is None: return diff --git a/scripts/node-onboarding/configure-site.py b/scripts/node-onboarding/configure-site.py index 7778a9f..f3e6738 100644 --- a/scripts/node-onboarding/configure-site.py +++ b/scripts/node-onboarding/configure-site.py @@ -23,6 +23,8 @@ def configure(plan,site): plan.pop('command_profile',None) plan['adapter']=[str(Path(sys.executable).resolve()),str(base/'command-adapter.py')] paths=[base/x for x in ('batch.py','site_adapter.py','ssh_pool.py','acceptance.py')]+[Path(site[k]) for k in ('ssh_config','exit_registry')] + if any(h.get('credential_format')=='gemdocs-resource' for h in adapter.pool.config['hosts'].values()): + paths += [Path('/Users/hellcat/Documents/GemDocs/AI版/tools')/name for name in ('credentials.py','resources.py')] plan['runtime']={'kind':'ssh-site-v1','python':str(Path(sys.executable).resolve()),'paramiko_version':library.__version__,'files':{str(p):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths}} return plan diff --git a/scripts/node-onboarding/site_adapter.py b/scripts/node-onboarding/site_adapter.py index 189280d..19f6e4f 100644 --- a/scripts/node-onboarding/site_adapter.py +++ b/scripts/node-onboarding/site_adapter.py @@ -3,7 +3,7 @@ import base64,hashlib,json,os,shlex,threading,time from pathlib import Path from types import SimpleNamespace from ssh_pool import Pool,private_json -from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope,validate_impact +from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope,validate_impact,candidate_eligibility class SiteAdapter: def __init__(self,plan,directory,pool=None,atomic_writer=None,resource_lock=None): @@ -69,6 +69,26 @@ class SiteAdapter: for spec in source['nodes']: if spec['sni'] not in self.exit_registry:raise ValueError('Missing old-node exit expectation') + def capacity_gate(self,node,execute): + script=r"""from pathlib import Path +import json,shutil,subprocess,re +text=Path('/proc/meminfo').read_text() +match=re.search(r'^MemTotal:\s+(\d+) kB$',text,re.M) +if not match:raise RuntimeError('memory_unknown') +units=[] +for unit in ['XrayR','xrayr']: + data=subprocess.check_output(['systemctl','show',unit,'-p','LoadState'],text=True).strip() + if data not in ['LoadState=loaded','LoadState=not-found','LoadState=masked','LoadState=bad-setting','LoadState=error','LoadState=merged']:raise RuntimeError('xrayr_installation_unknown') + if data!='LoadState=not-found':units.append(unit) +paths=[p for p in ['/usr/local/XrayR/XrayR','/usr/local/bin/XrayR','/usr/bin/XrayR','/etc/XrayR/config.yml'] if Path(p).exists()] +installed=bool(units or paths or shutil.which('XrayR') or shutil.which('xrayr')) +print(json.dumps({'total_memory_bytes':int(match.group(1))*1024,'xrayr_installed':installed})) +""" + observation=json.loads(execute(node,"python3 - <<'DPLCAPACITY'\n"+script+"\nDPLCAPACITY")) + if not candidate_eligibility(observation.get('total_memory_bytes'),observation.get('xrayr_installed')): + raise RuntimeError('candidate_low_memory_with_xrayr') + return observation + def certificate_gate(self,n,node,read,execute): owner=read(node,'/etc/dpl-node/batch-owner.json') owned=bool(owner and json.loads(owner).get('request_sha256')==n['request_sha256']) @@ -325,6 +345,8 @@ print(json.dumps({'status':code,'cache':headers.get('cf-cache-status'),'control' return gateway_py(gateway,'Q='+repr(Q)+';SUB='+repr(ctx['subscription_url'])+'\n'+script) def phase_impl(panel,gateway,node): if a.phase=='preflight': + capacity=self.capacity_gate(node,execute) + self.atomic_json(self.directory/(key+'-capacity.json'),dict(capacity,request_sha256=sha,checked_at=int(time.time()))) with self.lock, self.resource_lock(self.directory.parent,ctx['panel_vps']+':panel'): execute(panel,'install -d -m 700 '+ctx['batch_backup_root']) execute(panel,'test "$(git -C '+OPS+' rev-parse HEAD)" = '+ctx['provision_commit']+' && test -z "$(git -C '+OPS+' status --porcelain)"') diff --git a/scripts/node-onboarding/ssh_pool.py b/scripts/node-onboarding/ssh_pool.py index 35b602a..a5e2692 100644 --- a/scripts/node-onboarding/ssh_pool.py +++ b/scripts/node-onboarding/ssh_pool.py @@ -19,6 +19,15 @@ def private_json(path): def credentials(spec): + if spec.get('credential_format') == 'gemdocs-resource': + import importlib.util,sys + root=Path('/Users/hellcat/Documents/GemDocs/AI版') + resource=spec.get('credential_resource','') + if not re.fullmatch(r'VPS-[0-9]{3}',resource):raise ValueError('Exact GemDocs VPS resource required') + if str(root/'tools') not in sys.path:sys.path.insert(0,str(root/'tools')) + module_spec=importlib.util.spec_from_file_location('gemdocs_credentials',root/'tools/credentials.py') + module=importlib.util.module_from_spec(module_spec);module_spec.loader.exec_module(module) + return {'password':module.get_field(root,resource,'password')} if not spec.get('credential_ref'):return {} if spec.get('credential_format','json')=='json':return private_json(spec['credential_ref']) if spec['credential_format']=='codocs-password': diff --git a/tests/node-onboarding/test_runtime.py b/tests/node-onboarding/test_runtime.py index cbb2f5d..c03a02a 100644 --- a/tests/node-onboarding/test_runtime.py +++ b/tests/node-onboarding/test_runtime.py @@ -13,7 +13,7 @@ from unittest.mock import patch SCRIPTS=Path(__file__).resolve().parents[2]/'scripts/node-onboarding' sys.path.insert(0,str(SCRIPTS)) -from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope +from acceptance import audit,digest,snapshot,service_change,provider_state,verification_scope,candidate_eligibility from ssh_pool import Pool,private_json,credentials import batch @@ -146,6 +146,55 @@ class ProbeDispatchTests(unittest.TestCase): with self.assertRaises(OSError):verifier.run_tls({'host':'relay.invalid','port':1234,'sni':'node.invalid'}) +class CapacityGateTests(unittest.TestCase): + def test_both_conditions_and_decimal_boundary(self): + self.assertFalse(candidate_eligibility(512*1024*1024,True)) + self.assertFalse(candidate_eligibility(999_999_999,True)) + self.assertTrue(candidate_eligibility(1_000_000_000,True)) + self.assertTrue(candidate_eligibility(1010876*1024,True)) # Kernel-visible RAM on nominal 1GB host. + self.assertTrue(candidate_eligibility(512*1024*1024,False)) + for amount,installed in [(None,True),(0,True),(True,True),(512000000,None),(512000000,'inactive')]: + with self.assertRaises(ValueError):candidate_eligibility(amount,installed) + + def test_gate_rejects_low_memory_or_unknown_and_has_no_write_command(self): + from site_adapter import SiteAdapter + adapter=object.__new__(SiteAdapter);commands=[] + def execute(node,command): + commands.append(command) + program=command.split('\n',1)[1].rsplit('\nDPLCAPACITY',1)[0];compile(program,'capacity-remote','exec') + return json.dumps({'total_memory_bytes':512*1024*1024,'xrayr_installed':True}) + with self.assertRaisesRegex(RuntimeError,'candidate_low_memory'):adapter.capacity_gate(None,execute) + self.assertEqual(len(commands),1);self.assertIn('/proc/meminfo',commands[0]);self.assertNotIn('systemctl stop',commands[0]) + with self.assertRaises(ValueError):adapter.capacity_gate(None,lambda *args:'{}') + + + def test_generated_probe_handles_inactive_masked_missing_and_unknown(self): + import types,io,contextlib + from site_adapter import SiteAdapter + adapter=object.__new__(SiteAdapter) + def observation(memory, states, installed_path=False): + result=[] + class FakePath: + def __init__(self,value):self.value=value + def read_text(self):return memory + def exists(self):return installed_path and self.value=='/usr/local/XrayR/XrayR' + def execute(node,command): + program=command.split('\n',1)[1].rsplit('\nDPLCAPACITY',1)[0] + output=io.StringIO() + with patch.dict(sys.modules,{'pathlib':types.SimpleNamespace(Path=FakePath),'shutil':types.SimpleNamespace(which=lambda _:None),'subprocess':types.SimpleNamespace(check_output=lambda argv,**kw:'LoadState='+states[argv[2]]+'\n')}),contextlib.redirect_stdout(output): + exec(compile(program,'actual-capacity-probe','exec'),{}) + result.append(json.loads(output.getvalue()));return output.getvalue() + adapter.capacity_gate(None,execute);return result[0] + absent={'XrayR':'not-found','xrayr':'not-found'} + self.assertFalse(observation('MemTotal: 500000 kB\n',absent)['xrayr_installed']) + for state in ['loaded','masked']: + with self.subTest(state=state),self.assertRaisesRegex(RuntimeError,'candidate_low_memory'): + observation('MemTotal: 500000 kB\n',dict(absent,XrayR=state)) + with self.assertRaisesRegex(RuntimeError,'candidate_low_memory'):observation('MemTotal: 500000 kB\n',absent,True) + self.assertTrue(observation('MemTotal: 1010876 kB\n',dict(absent,XrayR='loaded'))['xrayr_installed']) + with self.assertRaisesRegex(RuntimeError,'memory_unknown'):observation('SwapTotal: 9000000 kB\n',absent) + with self.assertRaisesRegex(RuntimeError,'xrayr_installation_unknown'):observation('MemTotal: 9000000 kB\n',dict(absent,XrayR='unexpected')) + class TransportTests(unittest.TestCase): def test_reuse_and_every_nonwhite_host_has_jump(self): calls=[] @@ -176,6 +225,17 @@ class TransportTests(unittest.TestCase): for t in threads:t.start() for t in threads:t.join(timeout=5) self.assertEqual(len(made),2);self.assertIsNot(made[0],made[1]);pool.close() + def test_gemdocs_reader_passes_exact_field_and_stops_on_error(self): + import types + secret=' fixture:with#spaces$ \n' + reader=types.SimpleNamespace(get_field=lambda root,rid,field:secret) + loader=types.SimpleNamespace(exec_module=lambda module:None) + with patch('importlib.util.spec_from_file_location',return_value=types.SimpleNamespace(loader=loader)),patch('importlib.util.module_from_spec',return_value=reader): + self.assertEqual(credentials({'credential_format':'gemdocs-resource','credential_resource':'VPS-030'}),{'password':secret}) + reader.get_field=lambda *args:(_ for _ in ()).throw(ValueError('missing field')) + with self.assertRaises(ValueError):credentials({'credential_format':'gemdocs-resource','credential_resource':'VPS-030'}) + with self.assertRaises(ValueError):credentials({'credential_format':'gemdocs-resource','credential_resource':'other-account'}) + def test_existing_codocs_credentials_are_read_without_copy_or_rewrite(self): with tempfile.TemporaryDirectory() as d: p=Path(d)/'vps.md';body='# Fixture\n密码:test-only-phrase\n';p.write_text(body);p.chmod(0o600)