#!/usr/bin/env python3 """Sertican SSL Agent 1.0. Read before running with administrative privileges. Python 3.10+ and the maintained 'cryptography' package are required. Linux web servers additionally require current Certbot with the selected official plugin. Private keys remain local. ucretsizssl.tr authorizes this exact verified domain set and receives bounded certificate metadata, never passwords or private keys. Default command is a read-only plan; --apply and --accept-terms are explicit. """ import argparse, base64, contextlib, datetime, hashlib, hmac, http.server, json import os, re, secrets, shlex, shutil, socket, ssl, subprocess, sys, threading, time import urllib.error, urllib.parse, urllib.request from pathlib import Path VERSION='1.1.0' PROFILE_B64='__SERTICAN_NATIVE_PROFILE__' LAST_API=0 API_INTERVAL=3 ROOT_CERTIFICATES={'isrg-root-x1.pem': 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZhekNDQTFPZ0F3SUJBZ0lSQUlJUXo3RFNRT05aUkdQZ3UyT0Npd0F3RFFZSktvWklodmNOQVFFTEJRQXcKVHpFTE1Ba0dBMVVFQmhNQ1ZWTXhLVEFuQmdOVkJBb1RJRWx1ZEdWeWJtVjBJRk5sWTNWeWFYUjVJRkpsYzJWaApjbU5vSUVkeWIzVndNUlV3RXdZRFZRUURFd3hKVTFKSElGSnZiM1FnV0RFd0hoY05NVFV3TmpBME1URXdORE00CldoY05NelV3TmpBME1URXdORE00V2pCUE1Rc3dDUVlEVlFRR0V3SlZVekVwTUNjR0ExVUVDaE1nU1c1MFpYSnUKWlhRZ1UyVmpkWEpwZEhrZ1VtVnpaV0Z5WTJnZ1IzSnZkWEF4RlRBVEJnTlZCQU1UREVsVFVrY2dVbTl2ZENCWQpNVENDQWlJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dJUEFEQ0NBZ29DZ2dJQkFLM29KSFAwRkRmem01NHJWeWdjCmg3N2N0OTg0a0l4dVBPWlhvSGozZGNLaS92VnFidllBVHlqYjNtaUdiRVNUdHJGai9SUVNhNzhmMHVveG15RisKMFRNOHVrajEzWG5mczdqL0V2RWhta3ZCaW9aeGFVcG1abXlQZmp4d3Y2MHBJZ2J6NU1EbWdLN2lTNCszbVg2VQpBNS9UUjVkOG1VZ2pVK2c0cms4S2I0TXUwVWxYaklCMHR0b3YwRGlOZXdOd0lSdDE4akE4K28rdTNkcGpxK3NXClQ4S09FVXQrend2by83VjNMdlN5ZTByZ1RCSWxESENOQXltZzRWTWs3QlBaN2htL0VMTktqRCtKbzJGUjNxeUgKQjVUMFkzSHNMdUp2VzVpQjRZbGNOSGxzZHU4N2tHSjU1dHVrbWk4bXhkQVE0UTdlMlJDT0Z2dTM5NmozeCtVQwpCNWlQTmdpVjUrSTNsZzAyZFo3N0RuS3hIWnU4QS9sSkJkaUIzUVcwS3RaQjZhd0JkcFVLRDlqZjFiMFNIelV2CktCZHMwcGpCcUFsa2QyNUhON3JPckZsZWFKMS9jdGFKeFFaQktUNVpQdDBtOVNUSkVhZGFvMHhBSDBhaG1iV24KT2xGdWhqdWVmWEtuRWdWNFdlMCtVWGdWQ3dPUGpkQXZCYkkrZTBvY1MzTUZFdnpHNnVCUUUzeERrM1N6eW5UbgpqaDhCQ05BdzFGdHhOclFIdXNFd01GeEl0NEk3bUtaOVlJcWlveW1DekxxOWd3UWJvb01EUWFIV0JmRWJ3cmJ3CnFIeUdPMGFvU0NxSTNIYWFkcjhmYXFVOUdZL3JPUE5rM3NnckRRb28vL2ZiNGhWQzFDTFFKMTNoZWY0WTUzQ0kKclU3bTJZczZ4dDBuVVc3L3ZHVDFNME5QQWdNQkFBR2pRakJBTUE0R0ExVWREd0VCL3dRRUF3SUJCakFQQmdOVgpIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJSNXRGbm1lN2JsNUFGemdBaUl5QnBZOXVtYmJqQU5CZ2txCmhraUc5dzBCQVFzRkFBT0NBZ0VBVlI5WXFieXlxRkRRRExIWUdta2dKeWtJckdGMVhJcHUrSUxsYVMvVjlsWkwKdWJoekVGblRJWmQrNTB4eCs3TFNZSzA1cUF2cUZ5RldoZkZRRGxucnp1Qlo2YnJKRmUrR25ZK0VnUGJrNlpHUQozQmViWWh0RjhHYVYwbnh2d3VvNzd4L1B5OWF1Si9HcHNNaXUvWDErbXZvaUJPdi8yWC9xa1NzaXNSY09qL0tLCk5GdFkyUHdCeVZTNXVDYk1pb2d6aVV3dGhEeUMzKzZXVndXNkxMdjN4TGZIVGp1Q3ZqSElJbk56a3RIQ2dLUTUKT1JBekk0Sk1QSitHc2xXWUhiNHBob3dpbTU3aWF6dFhPb0p3VGR3Sng0bkxDZ2ROYk9oZGpzbnZ6cXZIdTdVcgpUa1hXU3RBbXpPVnl5Z2hxcFpYakZhSDNwTzNKTEYrbCsvK3NLQUl1dnRkN3UrTnhlNUFXMHdkZVJsTjhOd2RDCmpOUEVscHpWbWJVcTRKVWFnRWl1VERrSHpzeEhwRktWSzdxNCs2M1NNMU45NVIxTmJkV2hzY2RDYitaQUp6VmMKb3lpM0I0M25qVE9RNXlPZisxQ2NlV3hHMWJRVnM1WnVmcHNNbGpxNFVpMC8xbHZoK3dqQ2hQNGtxS09KMnF4cQo0Umdxc2FoRFlWdlRIOXc3alhieUxlaU5kZDhYTTJ3OVUvdDd5MEZmLzl5aTBHRTQ0WmE0ckYyTE45ZDExVFBBCm1SR3VuVUhCY25XRXZnSkJRbDluSkVpVTBac252Z2MvdWJoUGdYUlI0WHEzN1owajRyN2cxU2dFRXp3eEE1N2QKZW15UHhnY1l4bi9lUjQ0L0tKNEVCcytsVkRSM3ZleUptK2tYUTk5YjIxLytqaDVYb3MxQW5YNWlJdHJlR0NjPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==', 'isrg-root-x2.pem': 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNHekNDQWFHZ0F3SUJBZ0lRUWRLZDBYTHE3cWVBd1N4czZTK0hVakFLQmdncWhrak9QUVFEQXpCUE1Rc3cKQ1FZRFZRUUdFd0pWVXpFcE1DY0dBMVVFQ2hNZ1NXNTBaWEp1WlhRZ1UyVmpkWEpwZEhrZ1VtVnpaV0Z5WTJnZwpSM0p2ZFhBeEZUQVRCZ05WQkFNVERFbFRVa2NnVW05dmRDQllNakFlRncweU1EQTVNRFF3TURBd01EQmFGdzAwCk1EQTVNVGN4TmpBd01EQmFNRTh4Q3pBSkJnTlZCQVlUQWxWVE1Ta3dKd1lEVlFRS0V5QkpiblJsY201bGRDQlQKWldOMWNtbDBlU0JTWlhObFlYSmphQ0JIY205MWNERVZNQk1HQTFVRUF4TU1TVk5TUnlCU2IyOTBJRmd5TUhZdwpFQVlIS29aSXpqMENBUVlGSzRFRUFDSURZZ0FFelp2Vm40Q0RDdXdKU3ZNV1NqNWN6M2VzM21jRkRSMEh0dHdXCisxcUxGTnZpY1dERXVrV1ZFWW1PNmdiZjl5b1dIS1M1eGNVeTRBUGdIb0lZT0l2WFJkZ0thbTdtQUhmN0FsRjkKSXRnS2JwcGJkOS93K2tIc09keDF5bWdIREIvcW8wSXdRREFPQmdOVkhROEJBZjhFQkFNQ0FRWXdEd1lEVlIwVApBUUgvQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVZkVLV3J0NUxTRHY2a3ZpZWpNOXRpNmx5TjVVd0NnWUlLb1pJCnpqMEVBd01EYUFBd1pRSXdlM2xPUmxDRXdrU0hSaHRGY1A5WW1kNzAvYVRTVmFZZ0xYVFdOTHhCbzFCZkFTZFcKdEw0bmRRYXZFaTUxbUkzOEFqRUFpL1YzYk5USVphcmdDeXp1Rkowbk42VDVVNlZSNUNtRDEvaVFNVnRDbndyMQovcTRBYU9lTVNRKzJiMXRiRmZMbgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=='} class AgentError(RuntimeError): pass def profile_load(value=PROFILE_B64): p=json.loads(base64.b64decode(value)) if not re.fullmatch('[a-f0-9]{24}',p.get('id','')) or not re.fullmatch('[a-f0-9]{64}',p.get('key','')): raise AgentError('Invalid enrollment') if p.get('platform') not in ('nginx','apache','iis','zimbra'): raise AgentError('Invalid platform') if not 1<=len(p.get('domains',[]))<=10: raise AgentError('Invalid SAN count') for d in p['domains']: if len(d)>253 or not re.fullmatch(r'(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}',d): raise AgentError('Invalid DNS scope') u=urllib.parse.urlsplit(p['endpoint']) if u.scheme!='https' or u.hostname!='ucretsizssl.tr' or u.port not in (None,443) or u.path!='/sunucu-agent.php' or u.username or u.query or u.fragment: raise AgentError('Untrusted control endpoint') return p class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self,*args,**kwargs): return None def api(p, op, **values): global LAST_API, API_INTERVAL gap=API_INTERVAL-(time.monotonic()-LAST_API) if gap>0: time.sleep(gap) body=json.dumps({'op':op,**values},separators=(',',':')).encode(); nonce=secrets.token_hex(16); stamp=str(int(time.time())) sig=hmac.new(p['key'].encode(),stamp.encode()+b'\n'+nonce.encode()+b'\n'+body,hashlib.sha256).hexdigest() req=urllib.request.Request(p['endpoint'],data=body,headers={'Content-Type':'application/json','X-Sertican-ID':p['id'],'X-Sertican-Time':stamp,'X-Sertican-Nonce':nonce,'X-Sertican-Signature':sig}) opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(),urllib.request.HTTPSHandler(context=ssl.create_default_context())) try: response=opener.open(req,timeout=25) except urllib.error.HTTPError as err: response=err with response: raw=response.read(100000) signature=response.headers.get('X-Sertican-Signature','') expected=hmac.new(p['key'].encode(),nonce.encode()+b'\n'+raw,hashlib.sha256).hexdigest() if not hmac.compare_digest(signature,expected): raise AgentError('Control response authentication failed; check enrollment and clock') data=json.loads(raw) if data.get('nonce')!=nonce or not data.get('ok'): raise AgentError('Control: '+str(data.get('error','rejected'))) LAST_API=time.monotonic() if op=='policy': API_INTERVAL=max(3,min(30,int(data['data'].get('request_interval',3)))) return data['data'] def run(args,timeout=120,include_stderr=False): result=subprocess.run([str(x) for x in args],capture_output=True,text=True,timeout=timeout,shell=False) if result.returncode: raise AgentError('Command failed: '+str(args[0])+'; inspect the local service log (secret arguments are not printed)') return result.stdout+(result.stderr if include_stderr else '') def private_dir(path): path=Path(path).absolute() for parent in [path,*path.parents]: if parent.exists() and parent.is_symlink(): raise AgentError('Symlink storage is refused') path.mkdir(parents=True,exist_ok=True) if os.name=='nt': run(['icacls',str(path),'/inheritance:r','/grant:r','*S-1-5-18:(OI)(CI)F','*S-1-5-32-544:(OI)(CI)F']) else: if path.stat().st_uid!=0: raise AgentError('Agent storage must be root-owned') os.chmod(path,0o700) return path def atomic(path,data,mode=0o600): path=Path(path) if path.is_symlink(): raise AgentError('Refusing symlink destination') tmp=path.with_name(path.name+'.tmp-'+secrets.token_hex(5)); flags=os.O_CREAT|os.O_EXCL|os.O_WRONLY fd=os.open(str(tmp),flags,mode) try: with os.fdopen(fd,'wb') as f: f.write(data.encode() if isinstance(data,str) else data); f.flush(); os.fsync(f.fileno()) os.replace(tmp,path) if os.name!='nt': os.chmod(path,mode) finally: if tmp.exists(): tmp.unlink() @contextlib.contextmanager def lock(root): f=open(root/'agent.lock','a+b') try: if os.name=='nt': import msvcrt f.seek(0);f.write(b'0');f.flush();f.seek(0);msvcrt.locking(f.fileno(),msvcrt.LK_NBLCK,1) else: import fcntl fcntl.flock(f,fcntl.LOCK_EX|fcntl.LOCK_NB) yield finally: f.close() def crypto(): try: from cryptography import x509 from cryptography.hazmat.primitives import hashes,serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.serialization import pkcs12 return x509,hashes,serialization,rsa,pkcs12 except ImportError: raise AgentError('Install the maintained dependency first: python -m pip install cryptography') def cert_info(pem): x509,hashes,*_=crypto();c=x509.load_pem_x509_certificate(pem if isinstance(pem,bytes) else pem.encode()) names=sorted(c.extensions.get_extension_for_class(x509.SubjectAlternativeName).value.get_values_for_type(x509.DNSName)) return c,{'fingerprint':c.fingerprint(hashes.SHA256()).hex(),'expires':int(c.not_valid_after_utc.timestamp()),'valid_from':int(c.not_valid_before_utc.timestamp()),'domains':names} def check_scope(p,pem): cert,info=cert_info(pem) if info['domains']!=sorted(p['domains']): raise AgentError('Existing or issued SANs differ from the complete enrolled set; no certificate will be narrowed') return cert,info def doctor(p,args): data={'version':VERSION,'platform':sys.platform,'target':p['platform'],'domains':p['domains'],'python':sys.version.split()[0], 'privileged':is_admin(),'cryptography':True} crypto() if p['platform']=='iis': if os.name!='nt': raise AgentError('IIS profile requires Windows') data['powershell']=bool(shutil.which('powershell.exe'));data['site']=args.site if not data['powershell']: raise AgentError('Windows PowerShell / WebAdministration is required') else: if os.name=='nt': raise AgentError('Linux profile requires Linux') data['certbot']=bool(shutil.which('certbot'));data['openssl']=bool(shutil.which('openssl')) if p['platform']=='zimbra': data['zmcertmgr']=Path('/opt/zimbra/bin/zmcertmgr').is_file();data['zimbra_version']=run(['runuser','-l','zimbra','-c','/opt/zimbra/bin/zmcontrol -v'],30).strip() if data['zmcertmgr'] and is_admin() else 'not inspected' if not data['zmcertmgr']: raise AgentError('Zimbra Collaboration is not installed in /opt/zimbra') current=Path('/opt/zimbra/ssl/zimbra/commercial/commercial.crt') if current.is_file(): check_scope(p,current.read_bytes()) elif not data['certbot']: raise AgentError('Install current Certbot and the '+p['platform']+' plugin using certbot.eff.org/instructions') if args.webroot: web=Path(args.webroot).resolve(strict=True) if not web.is_dir(): raise AgentError('Webroot must be an existing directory') data['webroot']=str(web) return data def is_admin(): if os.name=='nt': import ctypes return bool(ctypes.windll.shell32.IsUserAnAdmin()) return os.geteuid()==0 def report(p,status,pem=b'',code=''): values={'status':status,'version':VERSION,'code':code} if pem: values.update({k:v for k,v in cert_info(pem)[1].items() if k in ('fingerprint','expires')}) return api(p,'report',**values) def tls_verify(domain,fingerprint,port=443,timeout=8): ctx=ssl.create_default_context() with socket.create_connection((domain,port),timeout=timeout) as raw: with ctx.wrap_socket(raw,server_hostname=domain) as tls: if hashlib.sha256(tls.getpeercert(binary_form=True)).hexdigest()!=fingerprint: raise AgentError('External TLS fingerprint differs (inspect CDN/origin configuration)') def backup_files(root,paths): folder=private_dir(root/'backups'/(time.strftime('%Y%m%dT%H%M%S')+'-'+secrets.token_hex(3))) manifest=[];allowed=[Path(x).resolve() for x in paths if Path(x).is_dir()];seen=set() for source in paths: source=Path(source) if source.is_symlink(): raise AgentError('Backup source is a symlink: '+str(source)) if not source.exists(): continue if source.is_dir(): for item in source.rglob('*'): if item.is_symlink(): target=item.resolve(strict=True) if not any(target.is_relative_to(base) for base in allowed): if 'modules-enabled' in item.parts: continue raise AgentError('Configuration symlink leaves the reviewed backup trees: '+str(item)) if target.is_file() and str(target) not in seen: _backup_one(target,folder,manifest);seen.add(str(target)) continue if item.is_file() and str(item) not in seen: _backup_one(item,folder,manifest);seen.add(str(item)) else: _backup_one(source,folder,manifest) atomic(folder/'manifest.json',json.dumps(manifest,indent=2));atomic(root/'last-backup.txt',str(folder));return folder def _backup_one(source,folder,manifest): stat=source.stat();dest=folder/hashlib.sha256(str(source).encode()).hexdigest();shutil.copyfile(source,dest) if os.name!='nt': os.chmod(dest,0o600) manifest.append({'path':str(source),'file':dest.name,'mode':stat.st_mode&0o777,'uid':getattr(stat,'st_uid',0),'gid':getattr(stat,'st_gid',0)}) def rollback_files(root): folder=Path((root/'last-backup.txt').read_text()).resolve() if root.resolve() not in folder.parents: raise AgentError('Invalid backup path') for row in json.loads((folder/'manifest.json').read_text()): path=Path(row['path']);path.parent.mkdir(parents=True,exist_ok=True);atomic(path,(folder/row['file']).read_bytes(),row['mode']) if os.name!='nt': os.chown(path,row['uid'],row['gid']) def certbot_run(p,args,root): # A dedicated lineage prevents the machine-wide Certbot timer bypassing this agent's policy. lineage=root/'letsencrypt'/'live'/p['domains'][0] if (lineage/'cert.pem').exists(): check_scope(p,(lineage/'cert.pem').read_bytes()) reviewed=set();wanted=set(p['domains']) if p['platform']=='nginx': config=run(['nginx','-T'],30) groups=[set(d.lower().rstrip('.') for d in names.split())-{'_'} for names in re.findall(r'\bserver_name\s+([^;]+);',config)] else: config=run([shutil.which('apachectl') or 'apache2ctl','-S'],30,include_stderr=True);groups=[] for line in config.splitlines(): match=re.search(r'(?:namevhost\s+|^\s*(?:\*|[0-9a-fA-F:.]+):\d+\s+)([^\s(]+)',line) if match: groups.append({match.group(1).lower().rstrip('.')}) alias=re.search(r'\balias\s+(\S+)',line) if alias and groups: groups[-1].add(alias.group(1).lower().rstrip('.')) for names in groups: if names&wanted: if names-wanted: raise AgentError('Shared virtual host contains additional aliases; enroll the complete SAN set before installation') reviewed|=names&wanted if reviewed!=wanted: raise AgentError('Every enrolled name must already have an explicit reviewed virtual host; default or wildcard fallback installation is refused') conf=['--config-dir',root/'letsencrypt','--work-dir',root/'work','--logs-dir',root/'logs'] command=[shutil.which('certbot'),'run','--'+p['platform'],'--non-interactive','--agree-tos','--email',p['email'],'--cert-name',p['domains'][0],'--keep-until-expiring','--reuse-key','--no-redirect',*conf] for d in p['domains']: command+=['-d',d] # Preserve service configuration locally before the official installer modifies it. paths=[Path('/etc/nginx')] if p['platform']=='nginx' else [x for x in (Path('/etc/apache2'),Path('/etc/httpd')) if x.exists()] backup_files(root,paths) run(command,600) pem=(lineage/'cert.pem').read_bytes();check_scope(p,pem);return pem def key_and_csr(p,root): x509,hashes,serialization,rsa,_=crypto();keyfile=root/'private-key.pem' if not keyfile.exists() and p['platform']=='zimbra': previous=Path('/opt/zimbra/ssl/zimbra/commercial/commercial.key') if previous.is_file() and not previous.is_symlink(): atomic(keyfile,previous.read_bytes()) if keyfile.exists(): key=serialization.load_pem_private_key(keyfile.read_bytes(),password=None) else: key=rsa.generate_private_key(public_exponent=65537,key_size=3072) atomic(keyfile,key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption())) if getattr(key,'key_size',0)<2048: raise AgentError('A strong RSA key is required; existing DANE keys are never silently replaced') subject=x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME,p['domains'][0])]);csr=x509.CertificateSigningRequestBuilder().subject_name(subject).add_extension(x509.SubjectAlternativeName([x509.DNSName(d) for d in p['domains']]),False).sign(key,hashes.SHA256()) return key,csr.public_bytes(serialization.Encoding.PEM).decode() @contextlib.contextmanager def challenge_server(root,p,args): challenges={};server=None;thread=None;written=[] if args.webroot: web=Path(args.webroot).resolve(strict=True) else: class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): host=self.headers.get('Host','').split(':')[0].lower();token=self.path.removeprefix('/.well-known/acme-challenge/') if host not in p['domains'] or token not in challenges or not self.path.startswith('/.well-known/acme-challenge/'): self.send_error(404);return data=challenges[token].encode();self.send_response(200);self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data) def log_message(self,*a): pass try: server=http.server.ThreadingHTTPServer(('0.0.0.0',80),Handler) except OSError: raise AgentError('Port 80 is in use. Provide an existing --webroot served on port 80; services are never stopped to free the port') server.timeout=1;thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() def publish(rows): for ch in rows: if ch['domain'] not in p['domains'] or not re.fullmatch('[A-Za-z0-9_-]{8,200}',ch['token']): raise AgentError('Challenge scope mismatch') if args.webroot: folder=web/'.well-known'/'acme-challenge' for parent in (web/'.well-known',folder): if parent.is_symlink(): raise AgentError('Challenge symlink refused') parent.mkdir(exist_ok=True) if os.name=='nt' and not (folder/'web.config').exists(): xml='' atomic(folder/'web.config',xml,0o644) written.append((folder/'web.config',xml)) file=folder/ch['token'] if file.exists() and file.read_text()!=ch['value']: raise AgentError('Another challenge owns this filename') if not file.exists(): atomic(file,ch['value'],0o644);written.append((file,ch['value'])) else: challenges[ch['token']]=ch['value'] try: yield publish finally: if server: server.shutdown();server.server_close() for file,value in written: if not file.is_symlink() and file.exists() and file.read_text()==value: file.unlink(missing_ok=True) def central_certificate(p,args,root): key,csr=key_and_csr(p,root);jobfile=root/'pending-job.json' with challenge_server(root,p,args) as publish: if jobfile.exists(): saved=json.loads(jobfile.read_text()) if saved.get('csr_hash')==hashlib.sha256(csr.encode()).hexdigest() and saved.get('expires',0)>time.time(): job=saved else: job=api(p,'start',csr=csr,accept_terms=True) else: job=api(p,'start',csr=csr,accept_terms=True) atomic(jobfile,json.dumps({**job,'csr_hash':hashlib.sha256(csr.encode()).hexdigest()})) deadline=time.monotonic()+900 while job['stage'] not in ('complete','staging_complete'): if time.monotonic()>deadline: raise AgentError('Issuance deadline reached; the pending job is retained for inspection') if job.get('challenges'): publish(job['challenges']) time.sleep(5);job=api(p,'step',job=job['id'],ready=job['stage']=='challenge') if job['environment']!='production': raise AgentError('Staging validation completed. Staging certificates will never be installed') pem=job['certificate'].encode();cert,info=check_scope(p,pem) from cryptography.hazmat.primitives import serialization if cert.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo)!=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo): raise AgentError('Issued certificate does not match the local private key') if cert.not_valid_before_utc>datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(minutes=5) or info['expires']0 and lifetime>0: remaining=min(remaining,lifetime//(2 if lifetime<864000 else 3)) return max(1,remaining) def renewal_schedule(root): path=root/'renewal-schedule.json' if path.is_symlink(): raise AgentError('Schedule symlink refused') if path.exists(): value=json.loads(path.read_text()) if type(value.get('hour')) is not int or type(value.get('minute')) is not int or not 0<=value['hour']<24 or not 0<=value['minute']<60: raise AgentError('Invalid local renewal schedule') return value value={'hour':secrets.randbelow(24),'minute':secrets.randbelow(60)} atomic(path,json.dumps(value));return value def renewal_retry(root, failed=None, now=None): now=int(time.time()) if now is None else now;path=root/'renewal-retry.json' if path.is_symlink(): raise AgentError('Retry symlink refused') state=json.loads(path.read_text()) if path.exists() else {} if failed is None: return int(state.get('not_before',0))>now attempts=min(20,max(0,int(state.get('attempts',0)))+1) if failed else 0 delay=(60,600,6000,86400)[min(attempts-1,3)] if attempts else 0 atomic(path,json.dumps({'attempts':attempts,'not_before':now+delay if attempts else 0})) return attempts def timer(p,args,root,remove=False): name='Sertican-'+p['id'];saved=root/'agent.py';config=root/'options.json' if os.name=='nt': if remove: run(['schtasks','/Delete','/TN',name,'/F']);return task=subprocess.list2cmdline([sys.executable,str(saved),'renew','--apply','--accept-terms','--scheduled','--state-dir',str(root)]) schedule=renewal_schedule(root) at=f"{schedule['hour']:02d}:{schedule['minute']:02d}" run(['schtasks','/Create','/TN',name,'/SC','DAILY','/ST',at,'/RU','SYSTEM','/RL','HIGHEST','/TR',task,'/F']) else: target=Path('/etc/cron.d')/name.lower() if remove: if target.is_symlink(): raise AgentError('Cron symlink refused') target.unlink(missing_ok=True);return if not Path('/etc/cron.d').is_dir() or not (shutil.which('cron') or shutil.which('crond')): raise AgentError('A working cron service is required') if any(x in str(saved)+sys.executable for x in ('\n','\r','%')): raise AgentError('Unsafe cron path') command=shlex.join([sys.executable,str(saved),'renew','--apply','--accept-terms','--scheduled','--state-dir',str(root)]) schedule=renewal_schedule(root);hour=schedule['hour']%12 atomic(target,f"{schedule['minute']} {hour},{hour+12} * * * root "+command+'\n',0o644) def main(argv=None): parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('action',choices=['doctor','plan','run','renew','status','install-timer','remove-timer','rollback'],nargs='?',default='plan') parser.add_argument('--apply',action='store_true');parser.add_argument('--accept-terms',action='store_true');parser.add_argument('--allow-restart',action='store_true');parser.add_argument('--webroot');parser.add_argument('--site');parser.add_argument('--create-binding',action='store_true');parser.add_argument('--state-dir');parser.add_argument('--version',action='version',version=VERSION) parser.add_argument('--scheduled',action='store_true',help='Timer-only randomized second and failure backoff') args=parser.parse_args(argv) if args.scheduled and args.action!='renew': parser.error('--scheduled is only valid for renew') p=profile_load();base=Path(os.environ.get('ProgramData','C:/ProgramData'))/'Sertican' if os.name=='nt' else Path('/var/lib/sertican');root=Path(args.state_dir).absolute() if args.state_dir else base/p['id'] if args.action in ('doctor','plan'): print(json.dumps({'doctor':doctor(p,args),'changes_planned':{'target':p['platform'],'certificate_scope':p['domains'],'server_policy_required':True,'restarts':p['platform']=='zimbra','timer_installed_automatically':False},'instruction':'Use run --apply --accept-terms after reviewing your backup and maintenance plan.'},indent=2));return if not is_admin(): raise AgentError('Run as root / elevated Administrator on the enrolled server') if not args.apply: raise AgentError('This action requires --apply') root=private_dir(root) if (root/'options.json').exists(): saved=json.loads((root/'options.json').read_text()) for key in ('webroot','site','allow_restart','create_binding'): if not getattr(args,key): setattr(args,key,saved.get(key)) if args.scheduled: time.sleep(secrets.randbelow(60)) with lock(root): if args.scheduled and renewal_retry(root): print('Renewal retry is deferred after a prior failure');return if args.action=='status': print((root/'last-report.json').read_text() if (root/'last-report.json').exists() else 'No successful local run');return if args.action=='remove-timer': timer(p,args,root,True);print('Only this agent timer removed; certificates and backup material preserved');return if args.action=='rollback': if p['platform']=='iis': iis_rollback(root);print('Only the enrolled SNI bindings restored from local metadata');return if p['platform']=='zimbra' and not args.allow_restart: raise AgentError('Rollback activation requires --allow-restart') if p['platform'] in ('nginx','apache'): run([shutil.which('certbot'),'--'+p['platform'],'rollback','--checkpoints','1','--config-dir',root/'letsencrypt','--work-dir',root/'work','--logs-dir',root/'logs'],180) rollback_files(root) if p['platform']=='zimbra': if not args.allow_restart: raise AgentError('Rollback activation requires --allow-restart') commercial=Path('/opt/zimbra/ssl/zimbra/commercial');run(['runuser','-u','zimbra','--','/opt/zimbra/bin/zmcertmgr','deploycrt','comm',commercial/'commercial.crt',commercial/'commercial_ca.crt'],180);run(['runuser','-l','zimbra','-c','/opt/zimbra/bin/zmcontrol restart'],300) else: run(['nginx','-t'] if p['platform']=='nginx' else [shutil.which('apachectl') or 'apache2ctl','configtest']);run(['systemctl','reload','nginx' if p['platform']=='nginx' else ('apache2' if Path('/etc/apache2').exists() else 'httpd')]) print('Local backup restored; check service health');return try: policy=api(p,'policy') except Exception: if args.action in ('run','renew'): renewal_retry(root,True) raise if policy['domains']!=p['domains'] or policy['platform']!=p['platform']: raise AgentError('Policy scope mismatch') if args.action=='install-timer': if not policy['timer']: raise AgentError('Agent scheduling disabled by administrator') if not (root/'agent.py').exists() or not (root/'options.json').exists(): raise AgentError('Complete one verified run before installing a timer') atomic(root/'agent.py',Path(__file__).read_bytes()) timer(p,args,root);print('Randomized domain-specific renewal timer installed; existing certificates preserved');return if not args.accept_terms: raise AgentError('--accept-terms requires review of https://letsencrypt.org/repository/') if not policy['install'] or not policy['issue'] or (args.action=='renew' and not policy['renew']): raise AgentError('Issuance, installation or renewal disabled by owner policy') if policy['environment']!='production': raise AgentError('Staging policy: deployment is blocked. Review CA settings with the administrator before selecting production') if p['platform']=='zimbra' and (not args.allow_restart or not policy['restart']): raise AgentError('Zimbra restart requires both owner and administrator permission') doctor(p,args) last=json.loads((root/'last-report.json').read_text()) if (root/'last-report.json').exists() else {} if args.action=='renew' and last.get('expires',0)>time.time()+renewal_threshold(last,policy['renew_days']): print('Certificate is not due for renewal');return atomic(root/'agent.py',Path(__file__).read_bytes());atomic(root/'options.json',json.dumps({k:getattr(args,k) for k in ('webroot','site','allow_restart','create_binding')})) try: if p['platform'] in ('nginx','apache'): api(p,'permit',accept_terms=True) pem=certbot_run(p,args,root) else: key,pem=central_certificate(p,args,root) if p['platform']=='zimbra': zimbra_install(p,args,root,pem) else: iis_install(p,args,root,key,pem) cert,info=check_scope(p,pem);remote=report(p,'installed',pem) atomic(root/'last-report.json',json.dumps({**info,'at':int(time.time()),'control':remote},indent=2)) for d in p['domains']: tls_verify(d,info['fingerprint']) # TLS on other Zimbra listeners is checked without modifying their configuration. if p['platform']=='zimbra': for port in (993,995): try: tls_verify(p['domains'][0],info['fingerprint'],port,5) except (ConnectionRefusedError,socket.timeout): pass # An unavailable optional listener is not enabled by this tool. time.sleep(3);report(p,'verified',pem);renewal_retry(root,False);print('Local deployment verified. Central HTTPS verification: '+remote.get('external','pending')) except Exception: renewal_retry(root,True) try: report(p,'failed',code='installation_or_verification_failed') except Exception: pass raise if __name__=='__main__': try: main() except (AgentError,OSError,ValueError,subprocess.SubprocessError) as exc: print('Sertican: '+str(exc),file=sys.stderr);sys.exit(1)