681 lines
27 KiB
Python
681 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, func
|
|
|
|
from app.api.dependencies import AppState, get_current_user, get_current_user_from_ws, get_db, get_state
|
|
from app.core.db import utc_now
|
|
from app.core.i18n import resolve_websocket_language, translate_message
|
|
from app.core.logging import get_logger
|
|
from app.models.connection import Connection
|
|
from app.models.session import SessionRecord
|
|
from app.schemas.api import (
|
|
SessionCreateRequest,
|
|
SessionOrderUpdateRequest,
|
|
SessionNoteUpdateRequest,
|
|
SessionResponse,
|
|
SessionStatusResponse,
|
|
TerminalMessage,
|
|
)
|
|
from app.services.crypto import CryptoService, EncryptedPayload
|
|
from app.services.session_updates import SessionBroadcaster
|
|
from app.services.ssh_manager import ManagedSession, SessionManager
|
|
from app.services.types import PtyInfo
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
_DA_RESPONSE_SEQUENCES: tuple[str, ...] = tuple(
|
|
sorted(
|
|
{
|
|
# 标准序列(带 ESC 前缀)
|
|
"\x1b[?1;2c", # xterm primary DA response
|
|
"\x1b[>0;276;0c", # xterm secondary DA response
|
|
"\x1b[?6c", # linux primary DA response
|
|
"\x1b[>85;95;0c", # rxvt secondary DA response
|
|
"\x1b[>83;40003;0c", # screen secondary DA response
|
|
# 兼容前缀缺失/被截断的变体(重连阶段实测会出现)
|
|
"[?1;2c",
|
|
"[>0;276;0c",
|
|
"[?6c",
|
|
"[>85;95;0c",
|
|
"[>83;40003;0c",
|
|
"?1;2c",
|
|
">0;276;0c",
|
|
"?6c",
|
|
">85;95;0c",
|
|
">83;40003;0c",
|
|
"0;276;0c",
|
|
"85;95;0c",
|
|
"83;40003;0c",
|
|
},
|
|
key=len,
|
|
reverse=True,
|
|
)
|
|
)
|
|
_DA_MAX_SEQUENCE_LEN = max(len(sequence) for sequence in _DA_RESPONSE_SEQUENCES)
|
|
_INITIAL_DA_SUPPRESS_SECONDS = 12.0
|
|
|
|
|
|
def _strip_da_response_sequences(data: str) -> str:
|
|
"""Strip known DA response sequences from payload and return remaining content."""
|
|
if not data:
|
|
return data
|
|
index = 0
|
|
remaining_parts: list[str] = []
|
|
data_len = len(data)
|
|
while index < data_len:
|
|
matched = False
|
|
for sequence in _DA_RESPONSE_SEQUENCES:
|
|
if data.startswith(sequence, index):
|
|
index += len(sequence)
|
|
matched = True
|
|
break
|
|
if not matched:
|
|
remaining_parts.append(data[index])
|
|
index += 1
|
|
return "".join(remaining_parts)
|
|
|
|
|
|
def _split_possible_da_prefix_suffix(data: str) -> tuple[str, str]:
|
|
"""Split payload into (safe_forward, pending_suffix) for fragmented DA response handling."""
|
|
if not data:
|
|
return "", ""
|
|
max_tail = min(len(data), _DA_MAX_SEQUENCE_LEN - 1)
|
|
for tail_len in range(max_tail, 0, -1):
|
|
suffix = data[-tail_len:]
|
|
if any(sequence.startswith(suffix) for sequence in _DA_RESPONSE_SEQUENCES):
|
|
return data[:-tail_len], suffix
|
|
return data, ""
|
|
|
|
|
|
class SessionPrepareResponse(BaseModel):
|
|
connection_id: int
|
|
remote_arch: str
|
|
remote_os: str
|
|
supports_enhanced: bool
|
|
first_time_enhance_available: bool
|
|
should_prompt_enhance: bool
|
|
|
|
|
|
def _serialize_session_status(record: SessionRecord) -> str:
|
|
return json.dumps(
|
|
{
|
|
"id": record.id,
|
|
"status": record.status,
|
|
"last_activity": record.last_activity.isoformat(),
|
|
"disconnected_at": record.disconnected_at.isoformat() if record.disconnected_at else None,
|
|
"auto_retry_count": record.auto_retry_count,
|
|
"retry_cycle_count": record.retry_cycle_count,
|
|
"allow_auto_retry": bool(record.allow_auto_retry),
|
|
"enhanced_enabled": bool(record.enhanced_enabled),
|
|
}
|
|
)
|
|
|
|
|
|
def _build_session_response(
|
|
record: SessionRecord,
|
|
conn: Connection,
|
|
managed_session: ManagedSession | None = None,
|
|
) -> SessionResponse:
|
|
target_profile = managed_session.target_profile if managed_session else "unknown"
|
|
target_rtt_ms = managed_session.target_rtt_ms if managed_session else None
|
|
target_avg_rtt_ms = managed_session.target_avg_rtt_ms if managed_session else None
|
|
target_jitter_ms = managed_session.target_jitter_ms if managed_session else 0
|
|
target_probe_error_streak = managed_session.target_probe_error_streak if managed_session else 0
|
|
target_measured_at = managed_session.target_measured_at if managed_session else None
|
|
return SessionResponse(
|
|
id=record.id,
|
|
connection_id=record.connection_id,
|
|
status=record.status,
|
|
started_at=record.started_at,
|
|
last_activity=record.last_activity,
|
|
host=conn.host,
|
|
username=conn.username,
|
|
name=conn.name,
|
|
note=record.note,
|
|
session_order=record.session_order or 0,
|
|
enhanced_enabled=bool(record.enhanced_enabled),
|
|
remote_arch=conn.remote_arch,
|
|
remote_os=conn.remote_os,
|
|
disconnected_at=record.disconnected_at,
|
|
auto_retry_count=record.auto_retry_count,
|
|
retry_cycle_count=record.retry_cycle_count,
|
|
allow_auto_retry=bool(record.allow_auto_retry),
|
|
target_profile=target_profile,
|
|
target_rtt_ms=target_rtt_ms,
|
|
target_avg_rtt_ms=target_avg_rtt_ms,
|
|
target_jitter_ms=target_jitter_ms,
|
|
target_probe_error_streak=target_probe_error_streak,
|
|
target_measured_at=target_measured_at,
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
|
|
|
|
|
@router.get("", response_model=list[SessionResponse])
|
|
def list_sessions(
|
|
state: AppState = Depends(get_state),
|
|
user=Depends(get_current_user),
|
|
db=Depends(get_db),
|
|
) -> list[SessionResponse]:
|
|
records = db.execute(
|
|
select(SessionRecord)
|
|
.where(SessionRecord.user_id == user.id)
|
|
.order_by(SessionRecord.session_order.asc(), SessionRecord.started_at.asc())
|
|
).scalars().all()
|
|
responses = []
|
|
for record in records:
|
|
conn = db.execute(select(Connection).where(Connection.id == record.connection_id)).scalar_one_or_none()
|
|
if not conn:
|
|
continue
|
|
managed_session = state.session_manager.get_session(record.id)
|
|
responses.append(_build_session_response(record, conn, managed_session))
|
|
return responses
|
|
|
|
|
|
@router.get("/{session_id}", response_model=SessionResponse)
|
|
def get_session(
|
|
session_id: str,
|
|
state: AppState = Depends(get_state),
|
|
user=Depends(get_current_user),
|
|
db=Depends(get_db),
|
|
) -> SessionResponse:
|
|
record = db.execute(
|
|
select(SessionRecord).where(SessionRecord.id == session_id, SessionRecord.user_id == user.id)
|
|
).scalar_one_or_none()
|
|
if not record:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
|
conn = db.execute(select(Connection).where(Connection.id == record.connection_id)).scalar_one_or_none()
|
|
if not conn:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
|
|
managed_session = state.session_manager.get_session(record.id)
|
|
return _build_session_response(record, conn, managed_session)
|
|
|
|
|
|
@router.post("/prepare/{connection_id}", response_model=SessionPrepareResponse)
|
|
async def prepare_session(
|
|
connection_id: int,
|
|
state: AppState = Depends(get_state),
|
|
user=Depends(get_current_user),
|
|
db=Depends(get_db),
|
|
) -> SessionPrepareResponse:
|
|
conn = db.execute(
|
|
select(Connection).where(Connection.id == connection_id, Connection.user_id == user.id)
|
|
).scalar_one_or_none()
|
|
if not conn:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
|
|
|
|
crypto = CryptoService(state.config.secret_keys)
|
|
auth_data = json.loads(conn.auth_data)
|
|
decrypted = crypto.decrypt(EncryptedPayload(nonce=auth_data["nonce"], ciphertext=auth_data["ciphertext"]))
|
|
auth_payload = json.loads(decrypted)
|
|
|
|
session_manager: SessionManager = state.session_manager
|
|
remote_arch, remote_os = await session_manager.detect_remote_platform(conn, auth_payload)
|
|
conn.remote_arch = remote_arch
|
|
conn.remote_os = remote_os
|
|
|
|
supports_enhanced = session_manager.resolve_keepalive_binary(remote_arch, remote_os) is not None
|
|
has_existing_enhanced = db.execute(
|
|
select(SessionRecord.id).where(
|
|
SessionRecord.connection_id == conn.id,
|
|
SessionRecord.user_id == user.id,
|
|
SessionRecord.enhanced_enabled.is_(True),
|
|
SessionRecord.enhanced_fingerprint.is_not(None),
|
|
SessionRecord.tmux_binary_path.is_not(None),
|
|
)
|
|
).first() is not None
|
|
first_time_enhance_available = supports_enhanced and not conn.enhance_prompt_shown and not has_existing_enhanced
|
|
should_prompt_enhance = supports_enhanced
|
|
|
|
logger.info(
|
|
"prepare_session decision conn_id=%s user_id=%s arch=%s os=%s supports_enhanced=%s prompt_shown=%s has_existing=%s first_time=%s should_prompt=%s keepalive_dir=%s",
|
|
conn.id,
|
|
user.id,
|
|
remote_arch,
|
|
remote_os,
|
|
supports_enhanced,
|
|
conn.enhance_prompt_shown,
|
|
has_existing_enhanced,
|
|
first_time_enhance_available,
|
|
should_prompt_enhance,
|
|
state.config.keepalive_binary_dir,
|
|
)
|
|
|
|
return SessionPrepareResponse(
|
|
connection_id=conn.id,
|
|
remote_arch=remote_arch,
|
|
remote_os=remote_os,
|
|
supports_enhanced=supports_enhanced,
|
|
first_time_enhance_available=first_time_enhance_available,
|
|
should_prompt_enhance=should_prompt_enhance,
|
|
)
|
|
|
|
|
|
@router.post("", response_model=SessionResponse)
|
|
async def create_session(
|
|
payload: SessionCreateRequest,
|
|
state: AppState = Depends(get_state),
|
|
user=Depends(get_current_user),
|
|
db=Depends(get_db),
|
|
) -> SessionResponse:
|
|
conn = db.execute(
|
|
select(Connection).where(Connection.id == payload.connection_id, Connection.user_id == user.id)
|
|
).scalar_one_or_none()
|
|
if not conn:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
|
|
|
|
crypto = CryptoService(state.config.secret_keys)
|
|
auth_data = json.loads(conn.auth_data)
|
|
decrypted = crypto.decrypt(EncryptedPayload(nonce=auth_data["nonce"], ciphertext=auth_data["ciphertext"]))
|
|
auth_payload = json.loads(decrypted)
|
|
pty = PtyInfo(term=payload.term, rows=payload.rows, cols=payload.cols)
|
|
|
|
session_manager: SessionManager = state.session_manager
|
|
broadcaster: SessionBroadcaster = state.session_broadcaster
|
|
|
|
logger.info(
|
|
"create_session request conn_id=%s user_id=%s enable_enhanced_persistence=%s remote_arch_cached=%s remote_os_cached=%s",
|
|
conn.id,
|
|
user.id,
|
|
payload.enable_enhanced_persistence,
|
|
conn.remote_arch,
|
|
conn.remote_os,
|
|
)
|
|
|
|
remote_arch = (conn.remote_arch or "").strip()
|
|
remote_os = (conn.remote_os or "").strip()
|
|
if not remote_arch or not remote_os:
|
|
detected_arch, detected_os = await session_manager.detect_remote_platform(conn, auth_payload)
|
|
conn.remote_arch = detected_arch
|
|
conn.remote_os = detected_os
|
|
remote_arch = detected_arch
|
|
remote_os = detected_os
|
|
|
|
binary_match = session_manager.resolve_keepalive_binary(remote_arch, remote_os) if payload.enable_enhanced_persistence else None
|
|
enable_enhanced = False
|
|
enhanced_fingerprint: str | None = None
|
|
tmux_binary_path: str | None = None
|
|
|
|
if payload.enable_enhanced_persistence:
|
|
if not binary_match:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前目标机器不支持增强持久化连接")
|
|
enhanced_fingerprint = f"kp_{conn.id}_{user.id}_{uuid.uuid4().hex}"
|
|
tmux_binary_path = binary_match[1]
|
|
enable_enhanced = True
|
|
|
|
if conn.enhance_prompt_shown is False and payload.enable_enhanced_persistence and binary_match is not None:
|
|
conn.enhance_prompt_shown = True
|
|
|
|
max_order = db.execute(
|
|
select(func.max(SessionRecord.session_order)).where(SessionRecord.user_id == user.id)
|
|
).scalar_one()
|
|
next_order = (max_order or 0) + 1
|
|
|
|
managed = await session_manager.create_session(
|
|
connection=conn,
|
|
auth_payload=auth_payload,
|
|
pty=pty,
|
|
enhanced_enabled=enable_enhanced,
|
|
enhanced_fingerprint=enhanced_fingerprint,
|
|
tmux_binary_path=tmux_binary_path,
|
|
)
|
|
|
|
record = SessionRecord(
|
|
id=managed.session_id,
|
|
connection_id=conn.id,
|
|
user_id=user.id,
|
|
status="active",
|
|
started_at=utc_now(),
|
|
last_activity=utc_now(),
|
|
pty_info=session_manager.serialize_pty(pty),
|
|
note=None,
|
|
session_order=next_order,
|
|
enhanced_enabled=enable_enhanced,
|
|
enhanced_fingerprint=enhanced_fingerprint,
|
|
tmux_binary_path=tmux_binary_path,
|
|
disconnected_at=None,
|
|
auto_retry_count=0,
|
|
retry_cycle_count=0,
|
|
allow_auto_retry=bool(enable_enhanced),
|
|
)
|
|
db.add(record)
|
|
|
|
response = _build_session_response(record, conn, managed)
|
|
await broadcaster.broadcast(user.id, response.model_dump_json())
|
|
return response
|
|
|
|
|
|
@router.post("/{session_id}/retry", response_model=SessionResponse)
|
|
async def retry_enhanced_session(
|
|
session_id: str,
|
|
state: AppState = Depends(get_state),
|
|
user=Depends(get_current_user),
|
|
db=Depends(get_db),
|
|
) -> SessionResponse:
|
|
record = db.execute(
|
|
select(SessionRecord).where(SessionRecord.id == session_id, SessionRecord.user_id == user.id)
|
|
).scalar_one_or_none()
|
|
if not record:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
|
if not record.enhanced_enabled or not record.enhanced_fingerprint or not record.tmux_binary_path:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该会话未开启增强持久化连接")
|
|
if record.status == "active":
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="会话当前在线,无需重试")
|
|
if not record.allow_auto_retry:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前会话已禁用重试")
|
|
|
|
conn = db.execute(select(Connection).where(Connection.id == record.connection_id)).scalar_one_or_none()
|
|
if not conn:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
|
|
|
|
crypto = CryptoService(state.config.secret_keys)
|
|
auth_data = json.loads(conn.auth_data)
|
|
decrypted = crypto.decrypt(EncryptedPayload(nonce=auth_data["nonce"], ciphertext=auth_data["ciphertext"]))
|
|
auth_payload = json.loads(decrypted)
|
|
|
|
session_manager: SessionManager = state.session_manager
|
|
broadcaster: SessionBroadcaster = state.session_broadcaster
|
|
pty = session_manager.deserialize_pty(record.pty_info)
|
|
|
|
last_error: Exception | None = None
|
|
retry_schedule = [2, 4, 8, 16, 32]
|
|
for attempt in range(1, len(retry_schedule) + 1):
|
|
try:
|
|
await session_manager.close_session(session_id)
|
|
managed = await session_manager.create_session(
|
|
connection=conn,
|
|
auth_payload=auth_payload,
|
|
pty=pty,
|
|
enhanced_enabled=True,
|
|
enhanced_fingerprint=record.enhanced_fingerprint,
|
|
tmux_binary_path=record.tmux_binary_path,
|
|
session_id=record.id,
|
|
)
|
|
record.id = managed.session_id
|
|
record.status = "active"
|
|
record.last_activity = utc_now()
|
|
record.disconnected_at = None
|
|
record.auto_retry_count += 1
|
|
record.retry_cycle_count = attempt
|
|
record.allow_auto_retry = True
|
|
response = _build_session_response(record, conn, managed)
|
|
await broadcaster.broadcast(user.id, response.model_dump_json())
|
|
return response
|
|
except Exception as exc:
|
|
last_error = exc
|
|
if attempt < len(retry_schedule):
|
|
await asyncio.sleep(retry_schedule[attempt - 1])
|
|
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(last_error) if last_error else "重试失败")
|
|
|
|
|
|
@router.post("/{session_id}/disconnect")
|
|
async def disconnect_session(
|
|
session_id: str,
|
|
state: AppState = Depends(get_state),
|
|
user=Depends(get_current_user),
|
|
db=Depends(get_db),
|
|
) -> dict:
|
|
record = db.execute(
|
|
select(SessionRecord).where(SessionRecord.id == session_id, SessionRecord.user_id == user.id)
|
|
).scalar_one_or_none()
|
|
if not record:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
|
|
|
session_manager: SessionManager = state.session_manager
|
|
broadcaster: SessionBroadcaster = state.session_broadcaster
|
|
if record.status == "active":
|
|
record.status = "disconnected"
|
|
record.last_activity = utc_now()
|
|
record.disconnected_at = utc_now()
|
|
record.allow_auto_retry = False
|
|
record.retry_cycle_count = 0
|
|
await session_manager.close_session(session_id, terminate_enhanced=True)
|
|
else:
|
|
record.allow_auto_retry = False
|
|
record.retry_cycle_count = 0
|
|
|
|
await broadcaster.broadcast(user.id, _serialize_session_status(record))
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.delete("/{session_id}")
|
|
async def delete_session(
|
|
session_id: str,
|
|
state: AppState = Depends(get_state),
|
|
user=Depends(get_current_user),
|
|
db=Depends(get_db),
|
|
) -> dict:
|
|
record = db.execute(
|
|
select(SessionRecord).where(SessionRecord.id == session_id, SessionRecord.user_id == user.id)
|
|
).scalar_one_or_none()
|
|
if not record:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
|
|
|
session_manager: SessionManager = state.session_manager
|
|
broadcaster: SessionBroadcaster = state.session_broadcaster
|
|
if record.status == "active":
|
|
record.status = "disconnected"
|
|
record.last_activity = utc_now()
|
|
record.disconnected_at = utc_now()
|
|
record.allow_auto_retry = False
|
|
record.retry_cycle_count = 0
|
|
await session_manager.close_session(session_id, terminate_enhanced=True)
|
|
await broadcaster.broadcast(user.id, _serialize_session_status(record))
|
|
|
|
db.delete(record)
|
|
await broadcaster.broadcast(
|
|
user.id,
|
|
SessionStatusResponse(id=record.id, status="deleted", last_activity=utc_now()).model_dump_json(),
|
|
)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.patch("/{session_id}/note", response_model=SessionResponse)
|
|
async def update_session_note(
|
|
session_id: str,
|
|
payload: SessionNoteUpdateRequest,
|
|
state: AppState = Depends(get_state),
|
|
user=Depends(get_current_user),
|
|
db=Depends(get_db),
|
|
) -> SessionResponse:
|
|
record = db.execute(
|
|
select(SessionRecord).where(SessionRecord.id == session_id, SessionRecord.user_id == user.id)
|
|
).scalar_one_or_none()
|
|
if not record:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
|
|
|
record.note = payload.note
|
|
record.last_activity = utc_now()
|
|
|
|
conn = db.execute(select(Connection).where(Connection.id == record.connection_id)).scalar_one_or_none()
|
|
if not conn:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
|
|
|
|
managed_session = state.session_manager.get_session(record.id)
|
|
response = _build_session_response(record, conn, managed_session)
|
|
broadcaster: SessionBroadcaster = state.session_broadcaster
|
|
await broadcaster.broadcast(user.id, response.model_dump_json())
|
|
return response
|
|
|
|
|
|
@router.patch("/order")
|
|
def update_session_order(
|
|
payload: SessionOrderUpdateRequest,
|
|
user=Depends(get_current_user),
|
|
db=Depends(get_db),
|
|
) -> dict:
|
|
records = db.execute(select(SessionRecord).where(SessionRecord.user_id == user.id)).scalars().all()
|
|
if not records:
|
|
return {"status": "ok"}
|
|
|
|
record_map = {record.id: record for record in records}
|
|
ordered_ids: list[str] = []
|
|
seen: set[str] = set()
|
|
|
|
for session_id in payload.ordered_ids:
|
|
if session_id in seen:
|
|
continue
|
|
if session_id not in record_map:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session not found")
|
|
ordered_ids.append(session_id)
|
|
seen.add(session_id)
|
|
|
|
existing_sorted = sorted(
|
|
records,
|
|
key=lambda record: (
|
|
1 if (record.session_order is None or record.session_order <= 0) else 0,
|
|
record.session_order or 0,
|
|
record.started_at,
|
|
),
|
|
)
|
|
for record in existing_sorted:
|
|
if record.id in seen:
|
|
continue
|
|
ordered_ids.append(record.id)
|
|
seen.add(record.id)
|
|
|
|
for index, session_id in enumerate(ordered_ids, start=1):
|
|
record_map[session_id].session_order = index
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.websocket("/ws/terminal/{session_id}")
|
|
async def terminal_socket(
|
|
websocket: WebSocket,
|
|
session_id: str,
|
|
state: AppState = Depends(get_state),
|
|
db=Depends(get_db),
|
|
) -> None:
|
|
await websocket.accept()
|
|
language = resolve_websocket_language(websocket)
|
|
try:
|
|
user = get_current_user_from_ws(websocket, state, db)
|
|
record = db.execute(select(SessionRecord).where(SessionRecord.id == session_id)).scalar_one_or_none()
|
|
if not record or record.user_id != user.id:
|
|
await websocket.close(code=1008)
|
|
return
|
|
|
|
session_manager: SessionManager = state.session_manager
|
|
session = session_manager.get_session(session_id)
|
|
if not session or session.status != "active" or not session.channel:
|
|
if record.status == "active":
|
|
record.status = "disconnected"
|
|
record.last_activity = utc_now()
|
|
record.disconnected_at = utc_now()
|
|
record.retry_cycle_count = 0
|
|
record.allow_auto_retry = bool(record.enhanced_enabled)
|
|
broadcaster: SessionBroadcaster = state.session_broadcaster
|
|
await broadcaster.broadcast(user.id, _serialize_session_status(record))
|
|
await websocket.send_text(translate_message("Session not active", language))
|
|
await websocket.close(code=1008)
|
|
return
|
|
|
|
bound_session = session
|
|
session.websockets.add(websocket)
|
|
try:
|
|
replay_outputs = session.buffer.dump()
|
|
# DA 响应(例如 \x1b[>0;276;0c)在重连阶段可能被误注入到远端 shell。
|
|
# 抑制窗口对每次 WS 建连都开启,不再依赖是否有回放缓冲数据。
|
|
suppress_initial_da_responses = True
|
|
for output in replay_outputs:
|
|
await websocket.send_text(output.data)
|
|
suppress_da_deadline = (
|
|
time.monotonic() + _INITIAL_DA_SUPPRESS_SECONDS if suppress_initial_da_responses else 0.0
|
|
)
|
|
da_fragment_buffer = ""
|
|
|
|
while True:
|
|
message = await websocket.receive_text()
|
|
payload = TerminalMessage.model_validate_json(message)
|
|
active_session = session_manager.get_session(session_id)
|
|
if not active_session or active_session.status != "active" or not active_session.channel:
|
|
await websocket.send_text(translate_message("Session not active", language))
|
|
await websocket.close(code=1008)
|
|
return
|
|
if payload.type == "input" and payload.data is not None:
|
|
data_to_send = payload.data
|
|
if (
|
|
suppress_initial_da_responses
|
|
and time.monotonic() <= suppress_da_deadline
|
|
):
|
|
merged = da_fragment_buffer + data_to_send
|
|
stripped = _strip_da_response_sequences(merged)
|
|
safe_forward, pending_suffix = _split_possible_da_prefix_suffix(stripped)
|
|
if safe_forward != merged or pending_suffix:
|
|
logger.info(
|
|
"ws-input-da-suppressed session_id=%s user_id=%s raw_bytes=%s forwarded_bytes=%s pending_bytes=%s",
|
|
session_id,
|
|
user.id,
|
|
len(merged),
|
|
len(safe_forward),
|
|
len(pending_suffix),
|
|
)
|
|
da_fragment_buffer = pending_suffix
|
|
data_to_send = safe_forward
|
|
elif da_fragment_buffer:
|
|
# 抑制窗口结束后释放残留分片,避免用户真实输入被长期缓存。
|
|
data_to_send = da_fragment_buffer + data_to_send
|
|
da_fragment_buffer = ""
|
|
if not data_to_send:
|
|
continue
|
|
await active_session.send(data_to_send)
|
|
if payload.type == "resize" and payload.rows and payload.cols:
|
|
logger.info(
|
|
"ws-resize-recv session_id=%s user_id=%s rows=%s cols=%s",
|
|
session_id,
|
|
user.id,
|
|
payload.rows,
|
|
payload.cols,
|
|
)
|
|
await active_session.resize(payload.rows, payload.cols)
|
|
try:
|
|
pty = session_manager.deserialize_pty(record.pty_info)
|
|
pty.rows = payload.rows
|
|
pty.cols = payload.cols
|
|
record.pty_info = session_manager.serialize_pty(pty)
|
|
logger.info(
|
|
"ws-resize-pty-updated session_id=%s rows=%s cols=%s",
|
|
session_id,
|
|
pty.rows,
|
|
pty.cols,
|
|
)
|
|
except Exception:
|
|
record.pty_info = session_manager.serialize_pty(
|
|
PtyInfo(term="xterm-256color", rows=payload.rows, cols=payload.cols)
|
|
)
|
|
logger.warning(
|
|
"ws-resize-pty-reset session_id=%s rows=%s cols=%s",
|
|
session_id,
|
|
payload.rows,
|
|
payload.cols,
|
|
)
|
|
except WebSocketDisconnect:
|
|
return
|
|
except Exception as exc:
|
|
logging.getLogger(__name__).warning("terminal_socket error: %s", exc)
|
|
return
|
|
finally:
|
|
bound_session.websockets.discard(websocket)
|
|
latest_session = session_manager.get_session(session_id)
|
|
if latest_session and latest_session is not bound_session:
|
|
latest_session.websockets.discard(websocket)
|
|
record.last_activity = utc_now()
|
|
except Exception as exc:
|
|
logging.getLogger(__name__).warning("terminal_socket setup error: %s", exc)
|
|
try:
|
|
await websocket.close(code=1008)
|
|
except Exception:
|
|
pass
|