#!/usr/bin/python3
"""Transactional local Apache/nginx synchronization; compatible with Python 3.5.

Only explicitly configured local-front hosts use this tool. It never creates
sites, requests certificates, or changes HTTP to HTTPS. All state is root-only.
"""
from __future__ import print_function
import argparse,calendar,contextlib,fcntl,hashlib,http.client,ipaddress,json,os,re,shlex,shutil,socket,ssl,subprocess,sys,tempfile,time
from pathlib import Path

VERSION='2026.10.19.3'
class SyncError(Exception):pass
@contextlib.contextmanager
def already_locked():yield

def atomic(path,data,mode=0o600):
 path=Path(path);path.parent.mkdir(parents=True,exist_ok=True)
 fd,tmp=tempfile.mkstemp(prefix='.'+path.name+'-',dir=str(path.parent))
 try:
  with os.fdopen(fd,'wb') as out:
   out.write(data.encode() if isinstance(data,str) else data);out.flush();os.fsync(out.fileno())
  os.chmod(tmp,mode);os.replace(tmp,str(path))
 finally:
  if os.path.exists(tmp):os.unlink(tmp)

def active(text):return '\n'.join(l for l in text.splitlines() if not l.lstrip().startswith('#'))
def directives(text,key):
 return [shlex.split(m.group(1))[0] for m in re.finditer(r'^[ \t]*'+re.escape(key)+r'[ \t]+([^\n]+)',text,re.M|re.I)]
def sites(text):
 result=[]
 for addresses,body in re.findall(r'<VirtualHost\s+([^>]+)>(.*?)</VirtualHost>',active(text),re.S|re.I):
  names=directives(body,'ServerName')
  if not names:raise SyncError('Every virtual host needs an explicit ServerName.')
  name=names[0].split(':')[0]
  if not re.match(r'^[a-zA-Z0-9_.-]+$',name):raise SyncError('Unsupported server name.')
  ports=set(int(p) for p in re.findall(r':(\d+)(?:\s|$)',addresses.strip()+' '))
  if not ports or ports-{80,443}:raise SyncError('Local frontend supports existing ports 80 and 443 only.')
  tls='on' in [x.lower() for x in directives(body,'SSLEngine')]
  if tls and ports!={443}:raise SyncError('TLS virtual host must use port 443 only.')
  if tls and any(v.lower()!='none' for v in directives(body,'SSLVerifyClient')):raise SyncError('Client-certificate authentication needs a reviewed frontend policy.')
  result.append(dict(name=name,ports=sorted(ports),tls=tls,body=body))
 return result

def assemble(settings,domain,current):
 """Preserve active fragment order; append new fragments deterministically."""
 root=Path(domain).resolve();found={}
 for p in sorted(root.iterdir()):
  if not p.is_dir():continue
  if p.is_symlink():raise SyncError('Refusing a symlinked domain directory.')
  conf=p/'etc/httpd.conf'
  if not conf.is_file():raise SyncError('Domain fragment missing: '+p.name)
  if conf.is_symlink():raise SyncError('Refusing a symlinked domain fragment.')
  found[str(conf)]=p.name
 order=[]
 for p in re.findall(r'^#From file (.+)$',current,re.M):
  if p not in found:raise SyncError('An active domain fragment is missing; review before removing a site.')
  if p not in order:order.append(p)
 order+=sorted(set(found)-set(order))
 text=Path(settings).read_text().rstrip()+'\n'
 for p in order:
  body=Path(p).read_text().replace('base.dom',found[p]).replace('[Domain]',found[p])
  text+='\n#From file '+p+'\n'+body.rstrip()+'\n'
 return text

class Front:
 def __init__(self,config='/usr/daemon/httpd/etc/local-nginx.json'):
  self.c=json.loads(Path(config).read_text());self.httpd=Path(self.c['httpd']);self.nginx=Path(self.c['nginx'])
  self.state=Path(self.c.get('state','/var/lib/httpd-front'));self.state.mkdir(mode=0o700,parents=True,exist_ok=True);os.chmod(str(self.state),0o700)
  self.apache=self.httpd/'conf/httpd.conf';self.front=self.nginx/'conf/nginx.conf'
  self.h=str(self.httpd/'bin/httpd');self.n=str(self.nginx/'sbin/nginx')
 def run(self,args,timeout=25):
  p=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=timeout)
  if p.returncode:raise SyncError('Configuration or service command failed: '+Path(args[0]).name)
  return p.stdout
 @contextlib.contextmanager
 def lock(self):
  with (self.state/'sync.lock').open('a') as out:
   fcntl.flock(out,fcntl.LOCK_EX|fcntl.LOCK_NB)
   yield
 def validate_apache(self,text):
  listens=directives(active(text),'Listen')
  if sorted(listens)!=['127.0.0.2:443','127.0.0.2:80']:raise SyncError('Apache must remain confined to 127.0.0.2 ports 80/443.')
  path=self.httpd/'conf/.front-candidate.conf'
  atomic(path,text)
  try:self.run([self.h,'-t','-f',str(path)])
  finally:
   if path.exists():path.unlink()
 def candidate(self,nginx_only=False):
  old=self.apache.read_text()
  return old if nginx_only else assemble(self.httpd/'conf/settings.httpd.conf',self.httpd/'domain',old)
 def render(self,text,revision):
  # Reuse the preserved frontend policy; only replace its generated server blocks.
  # Frontend defaults live in this reviewed root-owned policy file.
  policy=Path(self.c['policy']).read_text()
  if '# GENERATED SERVERS' not in policy:raise SyncError('Frontend policy is missing its generation marker.')
  prefix=policy.split('# GENERATED SERVERS',1)[0]
  inventory=sites(text);tls=[s for s in inventory if s['tls']]
  if self.c.get('require_tls') and not tls:raise SyncError('Refusing to remove every existing TLS frontend.')
  ip=self.c['address'];socket.inet_pton(socket.AF_INET,ip)
  chain_dir=self.nginx/'conf/cert-chains'/revision;chain_dir.mkdir(mode=0o755,parents=True,exist_ok=False)
  def listen(port,ssl_on=False,first=True):
   opts=' ssl' if ssl_on else ''
   return '\n'.join('listen '+a+':'+str(port)+opts+(extra if first else '')+';' for a,extra in [(ip,' bind'),('127.0.0.1',' bind'),('[::]',' ipv6only=on')])
  servers=' server {\n '+listen(80)+'\n server_name _;\n location / { proxy_pass http://127.0.0.2:80; }\n }\n'
  identities=[]
  for index,site in enumerate(tls):
   body=site['body'];name=site['name']
   certs=directives(body,'SSLCertificateFile');keys=directives(body,'SSLCertificateKeyFile')
   chains=directives(body,'SSLCertificateChainFile') or directives(body,'SSLCACertificateFile')
   if len(certs)!=1 or len(keys)!=1:raise SyncError('TLS certificate/key declaration needs review: '+name)
   pem=[]
   for path in certs+chains[:1]:
    for part in re.findall(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----',Path(path).read_text(),re.S):
     if part not in pem:pem.append(part)
   if not pem or not Path(keys[0]).is_file():raise SyncError('TLS certificate or key unavailable: '+name)
   chain=chain_dir/(name+'.pem');atomic(chain,'\n'.join(pem)+'\n',0o644)
   servers+=' server {\n '+listen(443,True,index==0)+'\n server_name '+name+';\n ssl_certificate '+str(chain)+';\n ssl_certificate_key '+keys[0]+';\n location / { proxy_pass https://127.0.0.2:443; }\n }\n'
   # Public metadata only; native validation also checks certificate/key agreement.
   der=self.run(['openssl','x509','-in',certs[0],'-outform','DER'])
   identities.append(dict(name=name,fingerprint=hashlib.sha256(der).hexdigest()))
  if not tls:servers+=' server {\n '+listen(443)+'\n server_name _;\n location / { proxy_pass http://127.0.0.2:443; }\n }\n'
  return prefix+'# GENERATED SERVERS\n'+servers+'}\n',identities,chain_dir
 def validate_nginx(self,text):
  path=self.nginx/'conf/.front-candidate.conf';atomic(path,text)
  try:self.run([self.n,'-t','-c',str(path)])
  finally:
   if path.exists():path.unlink()
 def probe(self,inventory):
  names={80:next((s['name'] for s in inventory if 80 in s['ports']),'unconfigured.fleet.invalid'),443:next((s['name'] for s in inventory if s['tls']),'unconfigured.fleet.invalid')}
  tls=any(s['tls'] for s in inventory)
  # Compare private backend and frontend with the same Host/SNI. Treat existing
  # application error codes as observations, not reasons to change applications.
  for port in (80,443):
   results=[]
   for address in ('127.0.0.2','127.0.0.1'):
    conn=http.client.HTTPConnection(address,port,timeout=4)
    if port==443 and tls:
     conn.connect();conn.sock=ssl._create_unverified_context().wrap_socket(conn.sock,server_hostname=names[port])
    conn.request('HEAD','/',headers={'Host':names[port],'Connection':'close'})
    response=conn.getresponse();results.append((response.status,response.getheader('Location'),response.getheader('Content-Type')));conn.close()
   if results[0]!=results[1]:raise SyncError('Frontend/backend health comparison failed on port '+str(port))
  return True
 def reload(self):
  self.run([self.h,'-k','graceful'])
  self.run([self.n,'-s','reload'])
 def applications(self,inventory):
  """Bounded read-only GET acceptance, using only declared host/port pairs."""
  results=[]
  checks=self.c.get('health_checks',[])
  if not isinstance(checks,list) or len(checks)>12:raise SyncError('Invalid application acceptance policy.')
  for check in checks:
   name=check['name'];port=check['port'];path=check['path'];tls=check.get('tls',False)
   if port not in (80,443) or not isinstance(path,str) or not path.startswith('/') or path.startswith('//') or re.search(r'[\r\n]',path):raise SyncError('Invalid application acceptance target.')
   if not any(s['name']==name and port in s['ports'] and s['tls']==tls for s in inventory):raise SyncError('Application acceptance target is not an active virtual host.')
   ok=True
   for address in ('127.0.0.2','127.0.0.1'):
    conn=http.client.HTTPConnection(address,port,timeout=4)
    try:
     if tls:
      conn.connect();conn.sock=ssl._create_unverified_context().wrap_socket(conn.sock,server_hostname=name)
     conn.request('GET',path,headers={'Host':name,'Connection':'close','Accept-Encoding':'identity','User-Agent':'Fleet-Acceptance/1.0'})
     response=conn.getresponse();body=response.read(1048577).decode('utf-8','replace').lower()
     valid=response.status==check.get('status',200) and len(body)<=1048576
     valid=valid and all(m.lower() in body for m in check.get('contains',[])) and not any(m.lower() in body for m in check.get('forbidden',[]))
     if check.get('location') is not None:valid=valid and response.getheader('Location')==check['location']
     ok=ok and valid
    except Exception:ok=False
    finally:conn.close()
   results.append(dict(name=name,path=path,port=port,tls=tls,ok=ok))
  return results
 def require_applications(self,inventory):
  rows=self.applications(inventory)
  if any(not r['ok'] for r in rows):raise SyncError('Application content acceptance failed; inspect website monitoring.')
  return rows
 def sync(self,check=False,nginx_only=False,locked=False):
  with (already_locked() if locked else self.lock()):
   text=self.candidate(nginx_only);self.validate_apache(text)
   revision=str(int(time.time()))+'-'+os.urandom(5).hex();chain_dir=self.nginx/'conf/cert-chains'/revision
   if any(self.state.glob('*/pending.json')):raise SyncError('An interrupted apply needs recovery before synchronization.')
   old_apache=self.apache.read_bytes();old_nginx=self.front.read_bytes()
   transaction=self.state/revision
   try:
    config,identities,chain_dir=self.render(text,revision);self.validate_nginx(config)
    # Refuse to make a service change when the selected applications already fail.
    # Check candidates also exercise current applications without activating files.
    self.require_applications(sites(old_apache.decode()))
    if check:return dict(ok=True,action='check',sites=len(sites(text)),tls_sites=len(identities))
    transaction.mkdir(mode=0o700)
    atomic(transaction/'httpd.conf',old_apache);atomic(transaction/'nginx.conf',old_nginx)
    atomic(transaction/'pending.json',json.dumps(dict(revision=revision,apache=str(self.apache),nginx=str(self.front))))
    try:
     atomic(self.apache,text,0o644);atomic(self.front,config,0o644)
     self.reload();time.sleep(.35);self.probe(sites(text));self.require_applications(sites(text))
     receipt=dict(ok=True,action='sync',revision=revision,sites=len(sites(text)),tls_sites=len(identities),certificates=identities,observed_at=int(time.time()))
     atomic(self.state/'last-success.json',json.dumps(receipt,sort_keys=True))
     (transaction/'pending.json').unlink()
     return receipt
    except BaseException:
     atomic(self.apache,old_apache,0o644);atomic(self.front,old_nginx,0o644)
     try:self.reload()
     except Exception:raise SyncError('Apply failed; files restored but service recovery needs operator review.')
     atomic(transaction/'recovered.json',json.dumps(dict(recovered=True,observed_at=int(time.time()))))
     (transaction/'pending.json').unlink()
     raise SyncError('Apply failed; previous Apache/nginx configuration restored and reloaded.')
   finally:
    # Never remove chains referenced by committed configuration. Old chain
    # generations remain available for rollback; failed/check generations go away.
    if chain_dir and (check or not self.front.exists() or str(chain_dir) not in self.front.read_text()):shutil.rmtree(str(chain_dir),ignore_errors=True)
 def recover_pending(self):
  with self.lock():
   recovered=0
   for pending in sorted(self.state.glob('*/pending.json')):
    transaction=pending.parent
    atomic(self.apache,(transaction/'httpd.conf').read_bytes(),0o644)
    atomic(self.front,(transaction/'nginx.conf').read_bytes(),0o644)
    self.reload();pending.unlink();recovered+=1
   return dict(ok=True,recovered=recovered)
 def status(self):
  result=dict(ok=True,version=VERSION,observed_at=int(time.time()),services={},checks=[],certificates=[],last_sync=None)
  for service,path,binary in [('Apache',self.httpd/'logs/httpd.pid',self.h),('nginx',self.nginx/'logs/nginx.pid',self.n)]:
   running=False
   try:
    pid=int(path.read_text().strip());running=os.path.realpath('/proc/'+str(pid)+'/exe')==os.path.realpath(binary)
   except (OSError,ValueError):pass
   result['services'][service]=running
  try:self.run([self.h,'-t']);self.run([self.n,'-t']);result['configuration_valid']=True
  except Exception:result['configuration_valid']=False
  try:self.probe(sites(self.apache.read_text()));result['frontend_backend_match']=True
  except Exception:result['frontend_backend_match']=False
  try:result['application_checks']=self.applications(sites(self.apache.read_text()));result['application_content_valid']=all(r['ok'] for r in result['application_checks'])
  except Exception:result['application_checks']=[];result['application_content_valid']=False
  try:
   policy=Path(self.c['policy']).read_text().split('# GENERATED SERVERS',1)[0]
   current=self.front.read_text().split('# GENERATED SERVERS',1)[0]
   result['frontend_policy_changed']=policy!=current
   ranges=re.findall(r'^\s*set_real_ip_from\s+([^;]+);',policy,re.M)
   result['trusted_cloudflare_ranges']=sorted(str(ipaddress.ip_network(n.strip())) for n in ranges)
  except Exception:result['frontend_policy_changed']=True;result['trusted_cloudflare_ranges']=[]
  result['source_configuration_changed']=False
  try:result['source_configuration_changed']=[l.strip() for l in active(self.candidate()).splitlines() if l.strip()]!=[l.strip() for l in active(self.apache.read_text()).splitlines() if l.strip()]
  except Exception:result['source_configuration_changed']=True
  for site in sites(self.apache.read_text()):
   if not site['tls']:continue
   try:
    path=directives(site['body'],'SSLCertificateFile')[0]
    # Observe the actual terminating frontend, not just renewed files on disk.
    with socket.create_connection(('127.0.0.1',443),timeout=2) as raw:
     with ssl._create_unverified_context().wrap_socket(raw,server_hostname=site['name']) as peer:der=peer.getpeercert(binary_form=True)
    parsed=subprocess.run(['openssl','x509','-inform','DER','-noout','-enddate'],input=der,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=5)
    if parsed.returncode:raise SyncError('Frontend certificate inspection failed.')
    data=parsed.stdout.decode().strip().split('=',1)[1]
    expiry=calendar.timegm(time.strptime(data,'%b %d %H:%M:%S %Y %Z'))
    if der!=self.run(['openssl','x509','-in',path,'-outform','DER']):result['source_configuration_changed']=True
    result['certificates'].append(dict(name=site['name'],expires=expiry,days_remaining=int((expiry-time.time())//86400)))
   except Exception:
    result['certificates'].append(dict(name=site['name'],expires=None,days_remaining=None))
    result['configuration_valid']=False
  last=self.state/'last-success.json'
  if last.is_file():result['last_sync']=json.loads(last.read_text()).get('observed_at')
  renewal=self.state/'renewal.json'
  if self.c.get('renewal_monitor_since'):
   result['renewal_status']='not_observed'
   try:
    record=json.loads(renewal.read_text());status=record.get('status');stamp=record.get('finished_at',record.get('started_at',0))
    if status in ('running','verified','failed'):
     result['renewal_status']='stale' if time.time()-stamp>(7200 if status=='running' else 172800) else status
     result['renewal_checked_at']=stamp
   except (OSError,ValueError):
    if time.time()-self.c['renewal_monitor_since']>172800:result['renewal_status']='stale'
  result['pending_recovery']=any(self.state.glob('*/pending.json'))
  result['healthy']=all(result['services'].values()) and result['configuration_valid'] and result['frontend_backend_match'] and result['application_content_valid'] and not result['pending_recovery'] and not result['frontend_policy_changed']
  return result

def main():
 p=argparse.ArgumentParser(description=__doc__);p.add_argument('--config',default='/usr/daemon/httpd/etc/local-nginx.json');p.add_argument('--check','--dry-run',action='store_true');p.add_argument('--nginx-only',action='store_true');p.add_argument('--status',action='store_true');p.add_argument('--recover-pending',action='store_true');p.add_argument('--json',action='store_true');p.add_argument('--quiet','-q',action='store_true');p.add_argument('--monitor',action='store_true');p.add_argument('--version',action='version',version=VERSION);a=p.parse_args()
 if os.geteuid()!=0:raise SyncError('Run as root.')
 front=Front(a.config)
 if a.monitor:
  front.recover_pending();r=front.status();atomic(front.state/'monitor.json',json.dumps(r,sort_keys=True));return
 elif a.status:r=front.status()
 elif a.recover_pending:r=front.recover_pending()
 else:r=front.sync(a.check,a.nginx_only)
 print(json.dumps(r,sort_keys=True) if a.json or a.status else 'Local Apache/nginx '+r['action']+' verified.')
if __name__=='__main__':
 try:main()
 except (Exception,KeyboardInterrupt) as e:
  # Native command output and configuration bodies stay private, never RPC/UI.
  print(json.dumps(dict(ok=False,error=str(e) if isinstance(e,SyncError) else 'Local frontend action failed; inspect private host state.')),file=sys.stderr);sys.exit(1)
