"""Minimal isolated, signed release receiver, not a shell/SSH command runner. Run behind TLS reverse proxy. The receiver has no database credentials and may only write under SITEFORGE_DEPLOY_ROOT. Never expose without TLS/firewall. """ from __future__ import annotations import hashlib,hmac,io,json,os,re,shutil,tempfile,time,zipfile from pathlib import Path,PurePosixPath from fastapi import FastAPI,HTTPException,Request from fastapi.responses import FileResponse app=FastAPI(title='SiteForge Release Agent',docs_url=None,redoc_url=None,openapi_url=None) ROOT=Path(os.environ.get('SITEFORGE_DEPLOY_ROOT','/srv/releases')).resolve() SITE=re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$') MAX_ZIP=120*1024*1024 MAX_EXPANDED=1024*1024*1024 def signature(secret,site,timestamp,body,method,path): # Bind each release signature to the exact HTTP action and target path; # otherwise an inventory signature could authorize a rollback endpoint. envelope=(f'{timestamp}\n{site}\n{method.upper()}\n{path}\n' f'{hashlib.sha256(body).hexdigest()}').encode('utf-8') return hmac.new(secret.encode(),envelope,hashlib.sha256).hexdigest() def authorize(site,request,body): ref=os.environ.get('SITEFORGE_DEPLOY_SECRET_REF','') if ref: from .secret_refs import resolve,SecretReferenceError try:secret=resolve(ref) except SecretReferenceError:raise HTTPException(503,'Release Agent secret unavailable') else:secret=os.environ.get('SITEFORGE_DEPLOY_SECRET','') if len(secret)<32:raise HTTPException(503,'Release agent secret not configured') stamp=request.headers.get('x-siteforge-timestamp','') try: if abs(int(time.time())-int(stamp))>300:raise ValueError except ValueError:raise HTTPException(401,'Invalid release timestamp') actual=request.headers.get('x-siteforge-signature','') if not hmac.compare_digest(signature(secret,site,stamp,body,request.method,request.url.path),actual):raise HTTPException(401,'Invalid release signature') def validate_archive(raw): if len(raw)>MAX_ZIP:raise ValueError('Archive too large') with zipfile.ZipFile(io.BytesIO(raw)) as z: if len(z.infolist())>20000:raise ValueError('Too many files') total=0;seen=set() for info in z.infolist(): name=info.filename path=PurePosixPath(name) if name.startswith('/') or '\\' in name or '..' in path.parts or not name or ':' in name or name.startswith('./'): raise ValueError('Unsafe archive path') key=name.lower().rstrip('/') if key in seen or key=='.siteforge-manifest.json': raise ValueError('Duplicate or reserved file in archive') seen.add(key) # Unix symlink type is 0o120000. if ((info.external_attr>>16)&0o170000)==0o120000:raise ValueError('Symlinks forbidden') total+=info.file_size if total>MAX_EXPANDED:raise ValueError('Expanded archive too large') if info.file_size>2000000 and info.compress_size and info.file_size/info.compress_size>200: raise ValueError('Suspicious compression ratio') if 'index.html' not in z.namelist():raise ValueError('Missing site index.html') return True def _pointer(site): parent=ROOT/site current=parent/'current' version=current.read_text(encoding='ascii').strip() if current.is_file() else None return version if version and re.fullmatch('[0-9a-f]{20}',version) else None def _manifest(root): """Hash all files of an immutable release. Called on a limited archive.""" hashes={} for path in sorted(root.rglob('*')): if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()): raise ValueError('Release contains unsafe link') if path.is_file():hashes[path.relative_to(root).as_posix()]=hashlib.sha256(path.read_bytes()).hexdigest() return hashes def stage(site,raw): """Stage an immutable release without changing current traffic.""" if not SITE.fullmatch(site):raise ValueError('Bad site ID') validate_archive(raw) digest=hashlib.sha256(raw).hexdigest()[:20] parent=ROOT/site releases=parent/'releases' releases.mkdir(parents=True,exist_ok=True) target=releases/digest if target.is_symlink():raise ValueError('Invalid immutable release directory') if not target.is_dir(): stage_dir=Path(tempfile.mkdtemp(prefix='.unpacked-',dir=str(parent))) try: with zipfile.ZipFile(io.BytesIO(raw)) as z:z.extractall(stage_dir) if not (stage_dir/'index.html').is_file():raise ValueError('Invalid site') hashes=_manifest(stage_dir) (stage_dir/'.siteforge-manifest.json').write_text(json.dumps({'sha256':hashlib.sha256(raw).hexdigest(), 'files':hashes},sort_keys=True),encoding='utf-8') try:stage_dir.rename(target) except FileExistsError:pass finally: if stage_dir.exists():shutil.rmtree(stage_dir) return {'site':site,'release':digest,'sha256':hashlib.sha256(raw).hexdigest(), 'staged':True,'current':_pointer(site)} def verify_release(site,release): if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise ValueError('Invalid release') folder=ROOT/site/'releases'/release meta=folder/'.siteforge-manifest.json' if not meta.is_file():raise ValueError('Release manifest missing') manifest=json.loads(meta.read_text(encoding='utf-8')) expected=manifest.get('files',{}) current=_manifest(folder) current.pop('.siteforge-manifest.json',None) if expected != current or 'index.html' not in current:raise ValueError('Release file integrity failure') return {'site':site,'release':release,'sha256':manifest['sha256'], 'files':len(current),'verified':True,'active':_pointer(site)==release, 'current':_pointer(site)} def activate(site,release): verified=verify_release(site,release) parent=ROOT/site previous=_pointer(site) tmp=parent/('.current-'+re.sub('[^0-9a-f]','',release)+'.tmp') tmp.write_text(release,encoding='ascii') os.replace(tmp,parent/'current') return {**verified,'active':True,'previous':previous,'current':release} def install(site,raw): """Compatibility endpoint used by existing clients; new Worker uses stage/verify/activate.""" staged=stage(site,raw) active=activate(site,staged['release']) return {**active,'published':True} @app.get('/health') def health():return {'healthy':True} @app.post('/deploy/{site}') async def deploy(site:str,request:Request): if not SITE.fullmatch(site):raise HTTPException(400,'Invalid site') if int(request.headers.get('content-length','0') or 0)>MAX_ZIP:raise HTTPException(413) parts=[];size=0 async for chunk in request.stream(): size+=len(chunk) if size>MAX_ZIP:raise HTTPException(413) parts.append(chunk) data=b''.join(parts) authorize(site,request,data) try:return install(site,data) except (ValueError,zipfile.BadZipFile) as e:raise HTTPException(400,str(e)) @app.post('/staging/{site}') async def stage_upload(site:str,request:Request): if not SITE.fullmatch(site):raise HTTPException(400) parts=[];size=0 async for chunk in request.stream(): size+=len(chunk) if size>MAX_ZIP:raise HTTPException(413) parts.append(chunk) raw=b''.join(parts) authorize(site,request,raw) try:return stage(site,raw) except (ValueError,zipfile.BadZipFile) as exc:raise HTTPException(400,str(exc)) @app.get('/sites/{site}/verify/{release}') def verify_route(site:str,release:str,request:Request): if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise HTTPException(400) authorize(site,request,b'') try:return verify_release(site,release) except (ValueError,OSError,KeyError) as exc:raise HTTPException(409,str(exc)) @app.post('/sites/{site}/activate/{release}') def activate_route(site:str,release:str,request:Request): if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise HTTPException(400) authorize(site,request,b'') try:return activate(site,release) except (ValueError,OSError,KeyError) as exc:raise HTTPException(409,str(exc)) @app.get('/sites/{site}/releases') def versions(site:str,request:Request): # No public access to release inventory. Require same signed request semantics. if not SITE.fullmatch(site):raise HTTPException(400) authorize(site,request,b'') parent=ROOT/site return {'current':(parent/'current').read_text().strip() if (parent/'current').exists() else None, 'releases':sorted(p.name for p in (parent/'releases').iterdir() if p.is_dir()) if (parent/'releases').is_dir() else []} @app.post('/sites/{site}/rollback/{release}') async def rollback(site:str,release:str,request:Request): if not SITE.fullmatch(site) or not re.fullmatch('[0-9a-f]{20}',release):raise HTTPException(400) authorize(site,request,b'') parent=ROOT/site if not (parent/'releases'/release/'index.html').is_file():raise HTTPException(404) if (parent/'releases'/release/'.siteforge-manifest.json').exists(): try:verify_release(site,release) except (ValueError,OSError,KeyError):raise HTTPException(409,'Historic release failed integrity verification') tmp=parent/'.current.tmp';tmp.write_text(release);os.replace(tmp,parent/'current') return {'site':site,'release':release,'rolled_back':True} @app.get('/public/{site}/{path:path}') def serve(site:str,path:str=''): if not SITE.fullmatch(site):raise HTTPException(404) parent=ROOT/site pointer=parent/'current' if not pointer.is_file():raise HTTPException(404) version=pointer.read_text().strip() if not re.fullmatch('[0-9a-f]{20}',version):raise HTTPException(404) root=(parent/'releases'/version).resolve() rel=path or 'index.html' candidate=(root/rel).resolve() if not candidate.is_relative_to(root):raise HTTPException(404) if candidate.is_dir() or not candidate.suffix:candidate=candidate/'index.html' if not candidate.is_file():raise HTTPException(404) return FileResponse(candidate)