This commit is contained in:
2026-04-19 11:33:11 +09:00
parent 867ae25fa0
commit da8d91b87f
43 changed files with 4044 additions and 15 deletions
+186 -2
View File
@@ -10,15 +10,30 @@ from pathlib import Path
from bs4 import BeautifulSoup
from markitdown import MarkItDown
from datetime import datetime, timezone
from fastapi import FastAPI, Request, Response
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.templating import Jinja2Templates
from fastapi.responses import PlainTextResponse, JSONResponse, FileResponse, RedirectResponse
from jinja2.exceptions import TemplateNotFound
from .error import error_page
from .database import AccessCounter
from .middleware import Middleware, server_version, onion_hostname
from .tools.tls_test import TlsJobQueue, TlsTestDB
from .tools.tls_test.engine import run_full_scan, parse_target
from .tools.tls_test.ratelimit import check as ratelimit_check, client_ip_from_scope
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
tls_test_db = TlsTestDB()
tls_test_queue = TlsJobQueue(tls_test_db, run_full_scan)
@asynccontextmanager
async def lifespan(app: FastAPI):
await tls_test_queue.start()
try:
yield
finally:
await tls_test_queue.stop()
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
app.add_middleware(Middleware)
templates = Jinja2Templates(directory=Path.cwd().joinpath("public"))
markitdown = MarkItDown()
@@ -129,6 +144,175 @@ async def thumbnail(request: Request, path: str) -> Response:
png = resvg_py.svg_to_bytes(svg, font_files=font_files, width=1200, height=630)
return Response(content=png, media_type="image/png")
def _validate_tls_target(raw: str) -> str | None:
s = (raw or "").strip()
if not s or not re.compile(r"^[A-Za-z0-9._:\[\]\-]{1,255}$").match(s):
return None
try:
host, port, _ = parse_target(s)
except Exception:
return None
if not host:
return None
if port <= 0 or port > 65535:
return None
return s
@app.api_route("/tools/tls-test/", methods=["GET"])
async def tls_test_index(request: Request) -> Response:
return templates.TemplateResponse(request=request, name="tools/tls-test/index.html")
@app.api_route("/tools/tls-test/", methods=["POST"])
async def tls_test_submit(request: Request) -> Response:
form = await request.form()
raw = str(form.get("target", "")).strip()
target = _validate_tls_target(raw)
if not target:
return templates.TemplateResponse(
request=request,
name="tools/tls-test/index.html",
context={"error": "無効なターゲットです。ホスト名/IP(:ポート)を入力してください。", "last_target": raw},
status_code=400,
)
client_ip = client_ip_from_scope(request.scope)
decision = ratelimit_check(tls_test_db, client_ip)
if not decision.allowed:
return templates.TemplateResponse(
request=request,
name="tools/tls-test/index.html",
context={"error": decision.reason, "last_target": raw},
status_code=429,
)
test_id = tls_test_queue.submit(target, client_ip)
return RedirectResponse(url=f"/tools/tls-test/status/{test_id}/", status_code=303)
@app.api_route("/tools/tls-test/status/{test_id}/", methods=["GET"])
async def tls_test_status_page(request: Request, test_id: str) -> Response:
job = tls_test_db.get_job(test_id)
if not job:
return error_page(templates, request, 404, "指定されたテストが見つかりません。", "…id間違ってない?")
if job.get("status") == "done":
return RedirectResponse(url=f"/tools/tls-test/results/{test_id}/", status_code=303)
progress = tls_test_db.get_progress(test_id)
return templates.TemplateResponse(
request=request,
name="tools/tls-test/status.html",
context={
"test_id": test_id,
"target": job.get("target", ""),
"status": job.get("status", ""),
"progress_entries": progress,
},
)
@app.api_route("/tools/tls-test/results/{test_id}/", methods=["GET"])
async def tls_test_results_page(request: Request, test_id: str) -> Response:
job = tls_test_db.get_job(test_id)
if not job:
return error_page(templates, request, 404, "指定されたテストが見つかりません。", "…id間違ってない?")
if job.get("status") != "done":
return RedirectResponse(url=f"/tools/tls-test/status/{test_id}/", status_code=303)
result = job.get("result") or {}
categories: dict[str, list[dict]] = {}
for f in result.get("findings", []):
categories.setdefault(f.get("category", "other"), []).append(f)
return templates.TemplateResponse(
request=request,
name="tools/tls-test/results.html",
context={
"test_id": test_id,
"job": job,
"result": result,
"categories": categories,
},
)
@app.websocket("/tools/tls-test/ws/{test_id}")
async def tls_test_ws(websocket: WebSocket, test_id: str):
job = tls_test_db.get_job(test_id)
if not job:
await websocket.close(code=4404)
return
await websocket.accept()
tls_test_queue.add_subscriber(test_id, websocket)
try:
history = tls_test_db.get_progress(test_id)
await websocket.send_text(json.dumps({
"type": "history",
"status": job.get("status"),
"target": job.get("target"),
"entries": history,
}))
if job.get("status") == "done":
await websocket.send_text(json.dumps({
"type": "done",
"redirect": f"/tools/tls-test/results/{test_id}/",
"rank": job.get("rank"),
"score": job.get("score"),
}))
await websocket.close()
return
while True:
try:
await websocket.receive_text()
except WebSocketDisconnect:
break
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
tls_test_queue.remove_subscriber(test_id, websocket)
@app.api_route("/api/tools/tls-test/scan", methods=["POST"])
async def tls_test_api_scan(request: Request) -> Response:
try:
payload = await request.json()
except Exception:
return JSONResponse({"error": "invalid JSON"}, status_code=400)
target = _validate_tls_target(str(payload.get("target", "")))
if not target:
return JSONResponse({"error": "invalid target"}, status_code=400)
client_ip = client_ip_from_scope(request.scope)
decision = ratelimit_check(tls_test_db, client_ip)
if not decision.allowed:
return JSONResponse({"error": decision.reason}, status_code=429)
test_id = tls_test_queue.submit(target, client_ip)
return JSONResponse({
"id": test_id,
"status_url": f"/tools/tls-test/status/{test_id}/",
"results_url": f"/tools/tls-test/results/{test_id}/",
"ws_url": f"/tools/tls-test/ws/{test_id}",
})
@app.api_route("/api/tools/tls-test/status/{test_id}", methods=["GET"])
async def tls_test_api_status(request: Request, test_id: str) -> Response:
job = tls_test_db.get_job(test_id)
if not job:
return JSONResponse({"error": "not found"}, status_code=404)
progress = tls_test_db.get_progress(test_id)
return JSONResponse({
"id": test_id,
"target": job.get("target"),
"status": job.get("status"),
"rank": job.get("rank"),
"score": job.get("score"),
"created_at": job.get("created_at"),
"started_at": job.get("started_at"),
"finished_at": job.get("finished_at"),
"progress": progress,
"error": job.get("error_message"),
})
@app.api_route("/api/tools/tls-test/results/{test_id}", methods=["GET"])
async def tls_test_api_results(request: Request, test_id: str) -> Response:
job = tls_test_db.get_job(test_id)
if not job:
return JSONResponse({"error": "not found"}, status_code=404)
if job.get("status") != "done":
return JSONResponse({"error": "not ready", "status": job.get("status")}, status_code=409)
return JSONResponse(job.get("result") or {})
@app.api_route("/{full_path:path}", methods=["GET", "POST", "HEAD"])
async def default_response(request: Request, full_path: str) -> Response:
if not full_path.endswith(".html") and not full_path.endswith(".md"):