From 66f30449821efb2a2504228385b2a008895ed72e Mon Sep 17 00:00:00 2001 From: hellcat Date: Tue, 20 Oct 2026 18:33:04 +0800 Subject: [PATCH] fix: preserve actual per-host service configuration in node batches --- docs/maintenance/NODE_BATCH_RUNTIME.md | 2 ++ scripts/node-onboarding/configure-site.py | 2 +- scripts/node-onboarding/site_adapter.py | 21 ++++++++++++++++- tests/node-onboarding/test_runtime.py | 28 +++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/maintenance/NODE_BATCH_RUNTIME.md b/docs/maintenance/NODE_BATCH_RUNTIME.md index 40f7b3a..9eed3d9 100644 --- a/docs/maintenance/NODE_BATCH_RUNTIME.md +++ b/docs/maintenance/NODE_BATCH_RUNTIME.md @@ -76,6 +76,8 @@ Certbot准备不能只检查`command -v`:还须实际运行`certbot --version` } ``` +各主机原服务路径不同且已通过实际进程参数核实时,可在site配置增加 `preserved_service_overrides`,例如 `{"VPS-023":{"config":"/usr/local/XrayR/config.yml"}}`。只允许候选主机ID,值为单独config或完整unit/binary/config/port四字段;路径必须为安全绝对路径。默认preserved_service保持不变,按当前主机合并保护规格,不搬动、复制或改写旧服务配置以适配脚本。现场依赖准备及收尾采集也必须使用同一已核实规格,不能仍硬编码默认路径。 + 批次备份目录必须独立且固定,不跨实际批次复用旧资源基线。新节点端口、正式客户端端口均查重预留;共享网关客户端还使用同一账本内的进程锁。当前适配器用于独立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文件。新计划统一设`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。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。 diff --git a/scripts/node-onboarding/configure-site.py b/scripts/node-onboarding/configure-site.py index f3e6738..621174f 100644 --- a/scripts/node-onboarding/configure-site.py +++ b/scripts/node-onboarding/configure-site.py @@ -11,7 +11,7 @@ from site_adapter import SiteAdapter def configure(plan,site): - allowed={'ops_root','node_root','batch_backup_root','node_backup_root','qa_root','provider_snapshot','exit_registry','ssh_config','preserved_service','formal_socks_port','gateway_containers','client_sha256','acceptance_impact'} + allowed={'ops_root','node_root','batch_backup_root','node_backup_root','qa_root','provider_snapshot','exit_registry','ssh_config','preserved_service','preserved_service_overrides','formal_socks_port','gateway_containers','client_sha256','acceptance_impact'} if set(site)-allowed:raise ValueError('Unexpected site configuration fields') library=importlib.import_module('paramiko') if library.__version__!='5.0.0':raise ValueError('Use the reviewed Paramiko 5.0.0 environment') diff --git a/scripts/node-onboarding/site_adapter.py b/scripts/node-onboarding/site_adapter.py index 19f6e4f..90638d1 100644 --- a/scripts/node-onboarding/site_adapter.py +++ b/scripts/node-onboarding/site_adapter.py @@ -13,6 +13,7 @@ class SiteAdapter: self.atomic_json,self.resource_lock=atomic_writer,resource_lock self.plan,self.directory=plan,Path(directory) self.ctx=plan['context'];self.lock=threading.RLock();self.leases={};self.formal_done={} + self.validate_service_overrides(self.ctx,plan['nodes']) for field in ('ops_root','node_root','batch_backup_root','node_backup_root','qa_root','provider_snapshot','exit_registry','ssh_config','client_binary'): value=self.ctx.get(field,'') if not isinstance(value,str) or not value.startswith('/') or '..' in value.split('/') or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./-' for c in value): @@ -124,8 +125,26 @@ if p.exists(): execute(node,"python3 - <<'DPLCERTGATE'\n"+prefix+script+"\nDPLCERTGATE") execute(node,'command -v git >/dev/null && command -v curl >/dev/null && command -v certbot >/dev/null && command -v ss >/dev/null && command -v sha256sum >/dev/null && command -v openssl >/dev/null') + @staticmethod + def validate_service_overrides(context,nodes): + overrides=context.get('preserved_service_overrides',{}) + if not isinstance(overrides,dict):raise ValueError('Invalid preserved service overrides') + hosts={n['vps_id'] for n in nodes} + for host,value in overrides.items(): + if host not in hosts or not isinstance(value,dict) or set(value) not in ({'config'},{'unit','binary','config','port'}): + raise ValueError('Unknown preserved service override host or fields') + for field in ('binary','config'): + if field not in value:continue + path=value[field] + if not isinstance(path,str) or not __import__('re').fullmatch('/[A-Za-z0-9_./-]+',path) or any(part in ('','.','..') for part in path.split('/')[1:]): + raise ValueError('Unsafe preserved service override path') + if 'unit' in value and (not isinstance(value['unit'],str) or not __import__('re').fullmatch('[A-Za-z0-9_@.-]+',value['unit'])): + raise ValueError('Unsafe preserved service override unit') + if 'port' in value and (type(value['port']) is not int or not 1<=value['port']<=65535): + raise ValueError('Unsafe preserved service override port') + def guard_service(self,node,backup,read,write,execute): - spec=self.ctx['preserved_service'] + spec={**self.ctx['preserved_service'],**self.ctx.get('preserved_service_overrides',{}).get(getattr(node,'rid',None),{})} for field in ('unit','binary','config','port'): if field not in spec:raise ValueError('Explicit preserved service specification required') if not all(isinstance(spec[k],str) and __import__('re').fullmatch('[A-Za-z0-9_./@-]+',spec[k]) for k in ('unit','binary','config')) or type(spec['port']) is not int: diff --git a/tests/node-onboarding/test_runtime.py b/tests/node-onboarding/test_runtime.py index c03a02a..fa48428 100644 --- a/tests/node-onboarding/test_runtime.py +++ b/tests/node-onboarding/test_runtime.py @@ -432,6 +432,27 @@ class SiteAdapterTests(unittest.TestCase): current['config_sha256']='changed' with self.assertRaisesRegex(RuntimeError,'conflict'):adapter.guard_service(None,'/backup',read,write,execute) self.assertEqual(json.loads(files['/backup/preserved-service-original.json']),original) + + def test_guard_service_selects_only_the_lazy_host_override(self): + import ast + from site_adapter import SiteAdapter,Lazy + adapter=object.__new__(SiteAdapter) + default=dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443) + adapter.ctx={'preserved_service':default,'preserved_service_overrides':{'VPS-A':{'config':'/usr/local/XrayR/config.yml'}}} + observed=[] + def execute(client,command): + if not command.startswith('python3'):return '' + script=command.split('\n',1)[1].rsplit('\n',1)[0] + spec=ast.literal_eval(next(n.value for n in ast.parse(script).body if isinstance(n,ast.Assign) and n.targets[0].id=='s')) + observed.append(spec) + return json.dumps(dict(active=True,listeners=['*:443'])) + for rid in ['VPS-A','VPS-B']: + adapter.guard_service(Lazy(None,rid),'/backup',lambda *a:None,lambda *a:None,execute) + self.assertEqual(observed,[dict(default,config='/usr/local/XrayR/config.yml'),default]) + self.assertEqual(adapter.ctx['preserved_service'],default) + del adapter.ctx['preserved_service_overrides'] + adapter.guard_service(Lazy(None,'VPS-A'),'/backup',lambda *a:None,lambda *a:None,execute) + self.assertEqual(observed[-1],default) def test_generated_remote_python_and_group_independent_php_compile(self): import ast import subprocess @@ -533,6 +554,13 @@ class SiteAdapterTests(unittest.TestCase): base=original.plan;node=base['nodes'][0];node['node_address']='192.0.2.11';node['expected_exit']='192.0.2.11';base['context'].update(jump_vps='VPS-001',panel_vps='VPS-002',gateway_vps='VPS-003');registry.write_text(json.dumps({node['tls_domain']:{'expected_exit':node['expected_exit'],'country':node['country']}})) site=dict(client_sha256='d'*64,ops_root='/ops',node_root='/node',batch_backup_root='/backup/batch',node_backup_root='/backup/nodes',qa_root='/qa/batch',provider_snapshot=str(root/'provider.json'),exit_registry=str(registry),ssh_config=str(ssh),preserved_service=dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443),formal_socks_port=19999,gateway_containers=['gateway-a']) with patch.dict(sys.modules,{'paramiko':types.SimpleNamespace(__version__='5.0.0')}): + host=node['vps_id'] + for override in [{'config':'/usr/local/XrayR/config.yml'},dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=443)]: + configured=module.configure(base,dict(site,preserved_service_overrides={host:override})) + self.assertEqual(configured['context']['preserved_service_overrides'],{host:override}) + for overrides in [{'VPS-UNKNOWN':{'config':'/safe.yml'}},{host:{'unknown':'/safe.yml'}},{host:{'binary':'/bin/xrayr'}},{host:{'config':'relative.yml'}},{host:{'config':'/etc/../secret'}},{host:{'config':'/etc/config;id'}},{host:{'config':'/etc//config'}},{host:dict(unit='XrayR',binary='/bin/xrayr',config='/etc/xrayr.yml',port=True)}]: + with self.subTest(overrides=overrides),self.assertRaises(ValueError): + module.configure(base,dict(site,preserved_service_overrides=overrides)) plan=module.configure(base,site);self.assertEqual(plan['runtime']['kind'],'ssh-site-v1');self.assertFalse((root/'ledger').exists()) runner=batch.Coordinator(plan,root/'ledger',jobs=1);runner.runtime.close();runner.check_runtime() ssh.write_text(ssh.read_text()+'\n')