#!/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)