171 lines
6.1 KiB
Python
171 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlalchemy import update
|
|
from starlette.responses import JSONResponse, Response
|
|
|
|
from app.api import auth, connections, health, sessions, system, ws_sessions
|
|
from app.api.dependencies import AppState, get_state
|
|
from app.api.middleware import RequestLoggingMiddleware
|
|
from app.core.db import utc_now
|
|
from app.core.i18n import localize_detail, resolve_http_language
|
|
from app.core.logging import get_logger, setup_logging
|
|
from app.core.state import build_state
|
|
from app.models.session import SessionRecord
|
|
from app.services.bootstrap import (
|
|
ensure_admin_user,
|
|
ensure_connection_arch_columns,
|
|
ensure_session_enhanced_columns,
|
|
ensure_session_note_column,
|
|
ensure_session_order_column,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
(
|
|
config,
|
|
database,
|
|
auth_service,
|
|
session_manager,
|
|
session_broadcaster,
|
|
start_enhanced_retry_worker,
|
|
start_session_sync_worker,
|
|
) = build_state()
|
|
|
|
# 配置日志
|
|
setup_logging(config.log_level)
|
|
|
|
database.create_tables()
|
|
ensure_session_note_column(database)
|
|
ensure_connection_arch_columns(database)
|
|
ensure_session_enhanced_columns(database)
|
|
ensure_session_order_column(database)
|
|
|
|
with database.session() as db_session:
|
|
password = ensure_admin_user(db_session, auth_service)
|
|
if password:
|
|
logger.warning("Initial admin password: %s", password)
|
|
|
|
# 服务器启动时,将所有 active 状态的会话标记为 disconnected
|
|
# 因为内存中的 SSH 连接已经丢失。
|
|
# 仅增强会话保留自动重连能力,普通会话一律禁用自动重连。
|
|
with database.session() as db_session:
|
|
result = db_session.execute(
|
|
update(SessionRecord)
|
|
.where(SessionRecord.status == "active")
|
|
.values(
|
|
status="disconnected",
|
|
last_activity=utc_now(),
|
|
disconnected_at=utc_now(),
|
|
retry_cycle_count=0,
|
|
allow_auto_retry=SessionRecord.enhanced_enabled,
|
|
)
|
|
)
|
|
if result.rowcount > 0:
|
|
logger.info("Marked %d stale sessions as disconnected", result.rowcount)
|
|
|
|
normalized = db_session.execute(
|
|
update(SessionRecord)
|
|
.where(
|
|
SessionRecord.enhanced_enabled.is_(False),
|
|
SessionRecord.allow_auto_retry.is_(True),
|
|
)
|
|
.values(allow_auto_retry=False)
|
|
)
|
|
if normalized.rowcount > 0:
|
|
logger.info("Normalized %d non-enhanced sessions with auto-retry disabled", normalized.rowcount)
|
|
|
|
app_state = AppState(
|
|
config=config,
|
|
database=database,
|
|
auth_service=auth_service,
|
|
session_manager=session_manager,
|
|
session_broadcaster=session_broadcaster,
|
|
start_enhanced_retry_worker=start_enhanced_retry_worker,
|
|
start_session_sync_worker=start_session_sync_worker,
|
|
)
|
|
|
|
app = FastAPI(title="WebSSH Gateway", version="0.1.0")
|
|
app.dependency_overrides[get_state] = lambda: app_state
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def _localized_http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
language = resolve_http_language(request)
|
|
localized_detail = localize_detail(exc.detail, language)
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
headers=exc.headers,
|
|
content={"detail": localized_detail},
|
|
)
|
|
|
|
@app.on_event("startup")
|
|
async def _start_retry_worker() -> None:
|
|
if app_state.start_enhanced_retry_worker:
|
|
app_state.start_enhanced_retry_worker()
|
|
if app_state.start_session_sync_worker:
|
|
app_state.start_session_sync_worker()
|
|
|
|
# 添加请求日志中间件
|
|
app.add_middleware(RequestLoggingMiddleware)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=config.cors_allow_origins or ["*"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.include_router(auth.router)
|
|
app.include_router(connections.router)
|
|
app.include_router(sessions.router)
|
|
app.include_router(ws_sessions.router)
|
|
app.include_router(health.router)
|
|
app.include_router(system.router)
|
|
|
|
static_dir = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
|
if static_dir.exists():
|
|
app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend")
|
|
|
|
index_path = static_dir / "index.html"
|
|
if index_path.exists():
|
|
index_bytes = index_path.read_bytes()
|
|
index_response = Response(content=index_bytes, media_type="text/html")
|
|
spa_exact_paths = {"/", "/sessions", "/force-password"}
|
|
spa_prefix_paths = ("/terminal/",)
|
|
|
|
def is_spa_document_request(request: Request) -> bool:
|
|
if request.method != "GET":
|
|
return False
|
|
if request.headers.get("authorization"):
|
|
return False
|
|
accept = (request.headers.get("accept") or "").lower()
|
|
if "text/html" not in accept:
|
|
return False
|
|
path = request.url.path
|
|
if path in spa_exact_paths:
|
|
return True
|
|
return any(path.startswith(prefix) for prefix in spa_prefix_paths)
|
|
|
|
@app.middleware("http")
|
|
async def spa_fallback(request, call_next):
|
|
if is_spa_document_request(request):
|
|
return index_response
|
|
response = await call_next(request)
|
|
if response.status_code != 404 or request.method != "GET":
|
|
return response
|
|
path = request.url.path
|
|
if path.startswith("/api") or path.startswith("/auth") or path.startswith("/sessions") or path.startswith("/system"):
|
|
return response
|
|
return index_response
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|