Files
cms-SiteForge/app/deploy_agent.py
T
浅念 8cea7dee20 chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests)
- Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR
  imports keep post/product types instead of collapsing to page
- Docs: trade-pro theme port spec (R3) and implementation plan

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 16:49:25 +08:00

128 lines
5.9 KiB
Python

"""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):
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
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:
raise ValueError('Unsafe archive path')
# 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 install(site,raw):
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 not target.is_dir():
stage=Path(tempfile.mkdtemp(prefix='.unpacked-',dir=str(parent)))
try:
with zipfile.ZipFile(io.BytesIO(raw)) as z:z.extractall(stage)
if not (stage/'index.html').is_file():raise ValueError('Invalid site')
try:stage.rename(target)
except FileExistsError:pass
finally:
if stage.exists():shutil.rmtree(stage)
pointer=parent/'.current.tmp'
pointer.write_text(digest,encoding='ascii')
os.replace(pointer,parent/'current')
return {'site':site,'release':digest,'sha256':hashlib.sha256(raw).hexdigest(),'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.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)
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)