fix: preserve actual per-host service configuration in node batches
This commit is contained in:
parent
4661c99706
commit
66f3044982
@ -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。配置和出口登记摘要固定,凭据只按需读取,不默认自动轮换。
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user