fix: preserve actual per-host service configuration in node batches

This commit is contained in:
hellcat 2026-10-20 18:33:04 +08:00
parent 4661c99706
commit 66f3044982
4 changed files with 51 additions and 2 deletions

View File

@ -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节点及明确列出的既有服务;不自动把白色机、面板或网关当节点宿主。 批次备份目录必须独立且固定,不跨实际批次复用旧资源基线。新节点端口、正式客户端端口均查重预留;共享网关客户端还使用同一账本内的进程锁。当前适配器用于独立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。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。 `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。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。

View File

@ -11,7 +11,7 @@ from site_adapter import SiteAdapter
def configure(plan,site): 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') if set(site)-allowed:raise ValueError('Unexpected site configuration fields')
library=importlib.import_module('paramiko') library=importlib.import_module('paramiko')
if library.__version__!='5.0.0':raise ValueError('Use the reviewed Paramiko 5.0.0 environment') if library.__version__!='5.0.0':raise ValueError('Use the reviewed Paramiko 5.0.0 environment')

View File

@ -13,6 +13,7 @@ class SiteAdapter:
self.atomic_json,self.resource_lock=atomic_writer,resource_lock self.atomic_json,self.resource_lock=atomic_writer,resource_lock
self.plan,self.directory=plan,Path(directory) self.plan,self.directory=plan,Path(directory)
self.ctx=plan['context'];self.lock=threading.RLock();self.leases={};self.formal_done={} 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'): 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,'') 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): 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,"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') 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): 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'): for field in ('unit','binary','config','port'):
if field not in spec:raise ValueError('Explicit preserved service specification required') 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: 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:

View File

@ -432,6 +432,27 @@ class SiteAdapterTests(unittest.TestCase):
current['config_sha256']='changed' current['config_sha256']='changed'
with self.assertRaisesRegex(RuntimeError,'conflict'):adapter.guard_service(None,'/backup',read,write,execute) with self.assertRaisesRegex(RuntimeError,'conflict'):adapter.guard_service(None,'/backup',read,write,execute)
self.assertEqual(json.loads(files['/backup/preserved-service-original.json']),original) 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): def test_generated_remote_python_and_group_independent_php_compile(self):
import ast import ast
import subprocess 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']}})) 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']) 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')}): 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()) 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() runner=batch.Coordinator(plan,root/'ledger',jobs=1);runner.runtime.close();runner.check_runtime()
ssh.write_text(ssh.read_text()+'\n') ssh.write_text(ssh.read_text()+'\n')