feat: add local hardware approval leases
This commit is contained in:
@@ -64,6 +64,9 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
||||
PHYSICAL_BENCH_COORDINATOR,
|
||||
SeatHardwareSettings,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
|
||||
PHYSICAL_BENCH_ACCESS,
|
||||
)
|
||||
from cognitive_agent.runtime.contracts import ContractValidator # noqa: E402
|
||||
|
||||
|
||||
@@ -140,6 +143,7 @@ class SeatDemoService:
|
||||
)
|
||||
self.hardware_settings = SeatHardwareSettings.from_env(repository_root)
|
||||
self.hardware_execution = PHYSICAL_BENCH_COORDINATOR
|
||||
self.hardware_access = PHYSICAL_BENCH_ACCESS
|
||||
# Shared by the short-lived hardware executor instances. A process must
|
||||
# establish a fresh physical four-axis reference before trusting stored
|
||||
# Hall counts, and a firmware replacement invalidates it again.
|
||||
@@ -181,6 +185,7 @@ class SeatDemoService:
|
||||
default_vehicle = self.vehicle_profiles.default()
|
||||
hardware_info = self.hardware_settings.public_info()
|
||||
hardware_info.update(self.hardware_execution.public_info())
|
||||
hardware_info.update(self.hardware_access.public_info())
|
||||
return {
|
||||
"service": "cabin-cognitive-orchestrator",
|
||||
"domain": "cabin",
|
||||
@@ -247,11 +252,19 @@ class SeatDemoService:
|
||||
self,
|
||||
conversation_id: str,
|
||||
vehicle_id: str | None = None,
|
||||
*,
|
||||
hardware_client_id: str | None = None,
|
||||
hardware_access_bypass: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Read the bench only when no physical execution currently owns it."""
|
||||
|
||||
if hardware_access_bypass:
|
||||
with self.hardware_execution.reserve():
|
||||
return self._synchronize_hardware_state_once(conversation_id, vehicle_id)
|
||||
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
||||
with self.hardware_execution.reserve():
|
||||
return self._synchronize_hardware_state_once(conversation_id, vehicle_id)
|
||||
with self.hardware_access.use(authorized_client_id):
|
||||
return self._synchronize_hardware_state_once(conversation_id, vehicle_id)
|
||||
|
||||
def _synchronize_hardware_state_once(
|
||||
self,
|
||||
@@ -322,6 +335,9 @@ class SeatDemoService:
|
||||
approval_token: str | None = None,
|
||||
vehicle_id: str | None = None,
|
||||
simulation_fault: str = "normal",
|
||||
*,
|
||||
hardware_client_id: str | None = None,
|
||||
hardware_access_bypass: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if execution_mode != "hardware":
|
||||
return self._process_once(
|
||||
@@ -333,16 +349,29 @@ class SeatDemoService:
|
||||
vehicle_id,
|
||||
simulation_fault,
|
||||
)
|
||||
if hardware_access_bypass:
|
||||
with self.hardware_execution.reserve():
|
||||
return self._process_once(
|
||||
utterance,
|
||||
provider,
|
||||
conversation_id,
|
||||
execution_mode,
|
||||
approval_token,
|
||||
vehicle_id,
|
||||
simulation_fault,
|
||||
)
|
||||
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
|
||||
with self.hardware_execution.reserve():
|
||||
return self._process_once(
|
||||
utterance,
|
||||
provider,
|
||||
conversation_id,
|
||||
execution_mode,
|
||||
approval_token,
|
||||
vehicle_id,
|
||||
simulation_fault,
|
||||
)
|
||||
with self.hardware_access.use(authorized_client_id):
|
||||
return self._process_once(
|
||||
utterance,
|
||||
provider,
|
||||
conversation_id,
|
||||
execution_mode,
|
||||
approval_token,
|
||||
vehicle_id,
|
||||
simulation_fault,
|
||||
)
|
||||
|
||||
def _process_once(
|
||||
self,
|
||||
|
||||
@@ -9,6 +9,11 @@ from .hardware import (
|
||||
PHYSICAL_BENCH_COORDINATOR,
|
||||
SeatHardwareSettings,
|
||||
)
|
||||
from .access import (
|
||||
HardwareAccessController,
|
||||
HardwareAccessRequiredError,
|
||||
PHYSICAL_BENCH_ACCESS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CanBus",
|
||||
@@ -16,10 +21,13 @@ __all__ = [
|
||||
"CanGatewayError",
|
||||
"CanMatrix",
|
||||
"HardwareBusyError",
|
||||
"HardwareAccessController",
|
||||
"HardwareAccessRequiredError",
|
||||
"HardwareExecutionCoordinator",
|
||||
"HardwareSafetyError",
|
||||
"MatrixError",
|
||||
"PHYSICAL_BENCH_COORDINATOR",
|
||||
"PHYSICAL_BENCH_ACCESS",
|
||||
"PythonCanBus",
|
||||
"SeatHardwareSettings",
|
||||
"ZlgCanBridgeBus",
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Local approval leases for access to the single physical seat bench."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Callable, Iterator
|
||||
|
||||
|
||||
HARDWARE_CLIENT_ID_PATTERN = re.compile(r"^hwc-[a-z0-9-]{8,80}$")
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class HardwareAccessRequiredError(PermissionError):
|
||||
"""Raised before CAN is opened when a terminal has no active lease."""
|
||||
|
||||
def __init__(self, state: str = "none") -> None:
|
||||
self.state = state
|
||||
messages = {
|
||||
"pending": "真实台架使用申请正在等待本地审批,本次请求未进入 CAN 链路。",
|
||||
"expired": "真实台架使用权因空闲超过 3 分钟已自动停止,请重新申请。",
|
||||
"none": "当前终端尚未获得真实台架使用权,请先向本地发送申请。",
|
||||
}
|
||||
super().__init__(messages.get(state, messages["none"]))
|
||||
|
||||
|
||||
class HardwareAccessController:
|
||||
"""Manage one locally-approved terminal and a bounded pending queue."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inactivity_timeout_seconds: int = 180,
|
||||
max_pending: int = 32,
|
||||
*,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
timestamp: Callable[[], str] = _utc_now,
|
||||
) -> None:
|
||||
if inactivity_timeout_seconds <= 0:
|
||||
raise ValueError("inactivity_timeout_seconds must be positive")
|
||||
if max_pending <= 0:
|
||||
raise ValueError("max_pending must be positive")
|
||||
self.inactivity_timeout_seconds = inactivity_timeout_seconds
|
||||
self.max_pending = max_pending
|
||||
self._clock = clock
|
||||
self._timestamp = timestamp
|
||||
self._lock = threading.RLock()
|
||||
self._pending: dict[str, dict[str, Any]] = {}
|
||||
self._active: dict[str, Any] | None = None
|
||||
self._expired_clients: set[str] = set()
|
||||
|
||||
@staticmethod
|
||||
def validate_client_id(client_id: str | None) -> str:
|
||||
value = (client_id or "").strip().lower()
|
||||
if not HARDWARE_CLIENT_ID_PATTERN.fullmatch(value):
|
||||
raise ValueError(
|
||||
"hardware_client_id must start with hwc- and contain 8-80 lowercase letters, numbers, or hyphens"
|
||||
)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def clean_label(label: str | None, client_id: str) -> str:
|
||||
value = " ".join((label or "").strip().split())
|
||||
if any(ord(character) < 32 for character in value):
|
||||
raise ValueError("terminal label must not contain control characters")
|
||||
return value[:64] or f"终端-{client_id[-6:]}"
|
||||
|
||||
def request_access(
|
||||
self,
|
||||
client_id: str | None,
|
||||
label: str | None,
|
||||
client_address: str,
|
||||
) -> dict[str, Any]:
|
||||
client_id = self.validate_client_id(client_id)
|
||||
clean_label = self.clean_label(label, client_id)
|
||||
with self._lock:
|
||||
self._expire_locked()
|
||||
self._expired_clients.discard(client_id)
|
||||
if self._active and self._active["client_id"] == client_id:
|
||||
return self._public_status_locked(client_id)
|
||||
existing = self._pending.get(client_id)
|
||||
if existing:
|
||||
existing.update(label=clean_label, client_address=client_address)
|
||||
else:
|
||||
if len(self._pending) >= self.max_pending:
|
||||
raise ValueError("hardware access request queue is full")
|
||||
self._pending[client_id] = {
|
||||
"client_id": client_id,
|
||||
"label": clean_label,
|
||||
"client_address": client_address,
|
||||
"requested_at": self._timestamp(),
|
||||
}
|
||||
return self._public_status_locked(client_id)
|
||||
|
||||
def status(self, client_id: str | None) -> dict[str, Any]:
|
||||
client_id = self.validate_client_id(client_id)
|
||||
with self._lock:
|
||||
self._expire_locked()
|
||||
return self._public_status_locked(client_id)
|
||||
|
||||
def admin_snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._expire_locked()
|
||||
active = None
|
||||
if self._active:
|
||||
active = dict(self._active)
|
||||
active.update(self._lease_timing_locked())
|
||||
active.pop("last_activity_monotonic", None)
|
||||
active.pop("active_executions", None)
|
||||
return {
|
||||
"policy": "local_approval_idle_lease",
|
||||
"inactivity_timeout_seconds": self.inactivity_timeout_seconds,
|
||||
"active": active,
|
||||
"pending": [dict(record) for record in self._pending.values()],
|
||||
}
|
||||
|
||||
def approve(self, client_id: str | None) -> dict[str, Any]:
|
||||
client_id = self.validate_client_id(client_id)
|
||||
with self._lock:
|
||||
self._expire_locked()
|
||||
request = self._pending.pop(client_id, None)
|
||||
if request is None:
|
||||
if self._active and self._active["client_id"] == client_id:
|
||||
return self.admin_snapshot()
|
||||
raise ValueError("hardware access request is no longer pending")
|
||||
now = self._clock()
|
||||
self._active = {
|
||||
**request,
|
||||
"approved_at": self._timestamp(),
|
||||
"last_activity_at": self._timestamp(),
|
||||
"last_activity_monotonic": now,
|
||||
"active_executions": 0,
|
||||
}
|
||||
self._expired_clients.discard(client_id)
|
||||
return self.admin_snapshot()
|
||||
|
||||
def deny(self, client_id: str | None) -> dict[str, Any]:
|
||||
client_id = self.validate_client_id(client_id)
|
||||
with self._lock:
|
||||
self._pending.pop(client_id, None)
|
||||
self._expired_clients.discard(client_id)
|
||||
return self.admin_snapshot()
|
||||
|
||||
def release(self, client_id: str | None) -> dict[str, Any]:
|
||||
client_id = self.validate_client_id(client_id)
|
||||
with self._lock:
|
||||
self._expire_locked()
|
||||
self._pending.pop(client_id, None)
|
||||
if self._active and self._active["client_id"] == client_id:
|
||||
self._active = None
|
||||
self._expired_clients.discard(client_id)
|
||||
return self._public_status_locked(client_id)
|
||||
|
||||
def revoke(self, client_id: str | None = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._expire_locked()
|
||||
if client_id:
|
||||
client_id = self.validate_client_id(client_id)
|
||||
if self._active and self._active["client_id"] != client_id:
|
||||
raise ValueError("the selected terminal does not own the hardware lease")
|
||||
self._active = None
|
||||
return self.admin_snapshot()
|
||||
|
||||
@contextmanager
|
||||
def use(self, client_id: str | None) -> Iterator[None]:
|
||||
client_id = self.validate_client_id(client_id)
|
||||
with self._lock:
|
||||
self._expire_locked()
|
||||
if not self._active or self._active["client_id"] != client_id:
|
||||
raise HardwareAccessRequiredError(self._state_locked(client_id))
|
||||
self._active["active_executions"] += 1
|
||||
self._touch_locked()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with self._lock:
|
||||
if self._active and self._active["client_id"] == client_id:
|
||||
self._active["active_executions"] = max(
|
||||
0, int(self._active["active_executions"]) - 1
|
||||
)
|
||||
self._touch_locked()
|
||||
|
||||
def ensure_authorized(self, client_id: str | None) -> str:
|
||||
"""Validate a lease without refreshing it for a failed/busy request."""
|
||||
|
||||
client_id = self.validate_client_id(client_id)
|
||||
with self._lock:
|
||||
self._expire_locked()
|
||||
if not self._active or self._active["client_id"] != client_id:
|
||||
raise HardwareAccessRequiredError(self._state_locked(client_id))
|
||||
return client_id
|
||||
|
||||
def public_info(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._expire_locked()
|
||||
return {
|
||||
"access_policy": "local_approval_idle_lease",
|
||||
"access_inactivity_timeout_seconds": self.inactivity_timeout_seconds,
|
||||
"access_pending_count": len(self._pending),
|
||||
"access_lease_active": self._active is not None,
|
||||
}
|
||||
|
||||
def _touch_locked(self) -> None:
|
||||
if self._active:
|
||||
self._active["last_activity_monotonic"] = self._clock()
|
||||
self._active["last_activity_at"] = self._timestamp()
|
||||
|
||||
def _expire_locked(self) -> None:
|
||||
if not self._active:
|
||||
return
|
||||
if int(self._active.get("active_executions", 0)) > 0:
|
||||
return
|
||||
idle = self._clock() - float(self._active["last_activity_monotonic"])
|
||||
if idle < self.inactivity_timeout_seconds:
|
||||
return
|
||||
self._expired_clients.add(str(self._active["client_id"]))
|
||||
self._active = None
|
||||
|
||||
def _state_locked(self, client_id: str) -> str:
|
||||
if self._active and self._active["client_id"] == client_id:
|
||||
return "approved"
|
||||
if client_id in self._pending:
|
||||
return "pending"
|
||||
if client_id in self._expired_clients:
|
||||
return "expired"
|
||||
return "none"
|
||||
|
||||
def _lease_timing_locked(self) -> dict[str, Any]:
|
||||
if not self._active:
|
||||
return {}
|
||||
if int(self._active.get("active_executions", 0)) > 0:
|
||||
return {
|
||||
"idle_seconds": 0,
|
||||
"idle_remaining_seconds": self.inactivity_timeout_seconds,
|
||||
"active_execution": True,
|
||||
}
|
||||
idle = max(0.0, self._clock() - float(self._active["last_activity_monotonic"]))
|
||||
return {
|
||||
"idle_seconds": int(idle),
|
||||
"idle_remaining_seconds": max(
|
||||
0, self.inactivity_timeout_seconds - int(idle)
|
||||
),
|
||||
}
|
||||
|
||||
def _public_status_locked(self, client_id: str) -> dict[str, Any]:
|
||||
state = self._state_locked(client_id)
|
||||
result: dict[str, Any] = {
|
||||
"state": state,
|
||||
"client_id": client_id,
|
||||
"inactivity_timeout_seconds": self.inactivity_timeout_seconds,
|
||||
}
|
||||
if state == "approved":
|
||||
result.update(self._lease_timing_locked())
|
||||
result["approved_at"] = self._active["approved_at"]
|
||||
result["last_activity_at"] = self._active["last_activity_at"]
|
||||
elif state == "pending":
|
||||
pending_ids = list(self._pending)
|
||||
result["queue_position"] = pending_ids.index(client_id) + 1
|
||||
result["requested_at"] = self._pending[client_id]["requested_at"]
|
||||
return result
|
||||
|
||||
|
||||
PHYSICAL_BENCH_ACCESS = HardwareAccessController()
|
||||
@@ -32,6 +32,9 @@ from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
||||
HardwareBusyError,
|
||||
HardwareSafetyError,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
|
||||
HardwareAccessRequiredError,
|
||||
)
|
||||
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError # noqa: E402
|
||||
|
||||
|
||||
@@ -96,6 +99,35 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if not self._authorize(path):
|
||||
return
|
||||
if path in {"/hardware-control", "/hardware-control.html"}:
|
||||
if not self._require_local_decision_point():
|
||||
return
|
||||
self._serve_static_file("hardware-control.html")
|
||||
return
|
||||
if path in {"/hardware-control.js", "/hardware-control.css"}:
|
||||
if not self._require_local_decision_point():
|
||||
return
|
||||
self._serve_static_file(path.lstrip("/"))
|
||||
return
|
||||
if path == "/api/local/hardware-access":
|
||||
if not self._require_local_decision_point():
|
||||
return
|
||||
self._json(self.service.hardware_access.admin_snapshot())
|
||||
return
|
||||
if path == "/api/hardware/access/status":
|
||||
query = parse_qs(parsed_url.query)
|
||||
try:
|
||||
self._json(
|
||||
self.service.hardware_access.status(
|
||||
str(query.get("client_id", [""])[0])
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
self._json(
|
||||
{"error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
return
|
||||
if path == "/api/health":
|
||||
self._json({"status": "ok", "service": "cabin-cognitive-orchestrator"})
|
||||
return
|
||||
@@ -106,13 +138,25 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
query = parse_qs(parsed_url.query)
|
||||
conversation_id = str(query.get("conversation_id", [""])[0])
|
||||
vehicle_id = str(query.get("vehicle_id", [""])[0]) or None
|
||||
hardware_client_id = str(query.get("hardware_client_id", [""])[0]) or None
|
||||
try:
|
||||
self._json(
|
||||
self.service.synchronize_hardware_state(
|
||||
conversation_id,
|
||||
vehicle_id,
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
)
|
||||
)
|
||||
except HardwareAccessRequiredError as exc:
|
||||
self._json(
|
||||
{
|
||||
"error": "hardware_access_required",
|
||||
"message": str(exc),
|
||||
"access_state": exc.state,
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
except HardwareBusyError as exc:
|
||||
self._json(
|
||||
{
|
||||
@@ -159,6 +203,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if not self._authorize(path):
|
||||
return
|
||||
if path in {
|
||||
"/api/hardware/access/request",
|
||||
"/api/hardware/access/release",
|
||||
"/api/local/hardware-access/approve",
|
||||
"/api/local/hardware-access/deny",
|
||||
"/api/local/hardware-access/revoke",
|
||||
}:
|
||||
self._handle_hardware_access_post(path)
|
||||
return
|
||||
if path != "/api/requests":
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
@@ -178,12 +231,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if self.auth.config.enabled:
|
||||
self.auth.api_requests.record(username)
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
if content_length <= 0 or content_length > 32768:
|
||||
raise ValueError("request body must be between 1 and 32768 bytes")
|
||||
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
payload = self._read_json_body()
|
||||
result = self.service.process(
|
||||
str(payload.get("utterance", "")),
|
||||
str(payload.get("provider", "auto")),
|
||||
@@ -200,8 +248,23 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
else None
|
||||
),
|
||||
simulation_fault=str(payload.get("simulation_fault", "normal")),
|
||||
hardware_client_id=(
|
||||
str(payload["hardware_client_id"])
|
||||
if payload.get("hardware_client_id")
|
||||
else None
|
||||
),
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
)
|
||||
self._json(result, HTTPStatus.CREATED)
|
||||
except HardwareAccessRequiredError as exc:
|
||||
self._json(
|
||||
{
|
||||
"error": "hardware_access_required",
|
||||
"message": str(exc),
|
||||
"access_state": exc.state,
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
except HardwareBusyError as exc:
|
||||
self._json(
|
||||
{
|
||||
@@ -238,6 +301,53 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def _handle_hardware_access_post(self, path: str) -> None:
|
||||
local_path = path.startswith("/api/local/")
|
||||
if local_path and not self._require_local_decision_point():
|
||||
return
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=4096)
|
||||
client_id = str(payload.get("client_id", ""))
|
||||
if path == "/api/hardware/access/request":
|
||||
status = self.service.hardware_access.request_access(
|
||||
client_id,
|
||||
str(payload.get("label", "")),
|
||||
self._client_key(),
|
||||
)
|
||||
self._json(
|
||||
status,
|
||||
HTTPStatus.OK if status["state"] == "approved" else HTTPStatus.ACCEPTED,
|
||||
)
|
||||
return
|
||||
if path == "/api/hardware/access/release":
|
||||
self._json(self.service.hardware_access.release(client_id))
|
||||
return
|
||||
if path == "/api/local/hardware-access/approve":
|
||||
self._json(self.service.hardware_access.approve(client_id))
|
||||
return
|
||||
if path == "/api/local/hardware-access/deny":
|
||||
self._json(self.service.hardware_access.deny(client_id))
|
||||
return
|
||||
if path == "/api/local/hardware-access/revoke":
|
||||
self._json(
|
||||
self.service.hardware_access.revoke(client_id or None)
|
||||
)
|
||||
return
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self._json(
|
||||
{"error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
def _read_json_body(self, max_bytes: int = 32768) -> dict[str, object]:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
if content_length <= 0 or content_length > max_bytes:
|
||||
raise ValueError(f"request body must be between 1 and {max_bytes} bytes")
|
||||
payload = json.loads(self.rfile.read(content_length).decode("utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
return payload
|
||||
|
||||
def _authorize(self, path: str) -> bool:
|
||||
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
|
||||
return True
|
||||
@@ -363,6 +473,35 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
return address
|
||||
|
||||
def _is_unproxied_loopback_request(self) -> bool:
|
||||
"""Distinguish the local decision console from traffic via the tunnel."""
|
||||
|
||||
try:
|
||||
direct_address = ipaddress.ip_address(self.client_address[0])
|
||||
except ValueError:
|
||||
return False
|
||||
proxy_headers = (
|
||||
"Forwarded",
|
||||
"X-Forwarded-For",
|
||||
"X-Real-IP",
|
||||
"CF-Connecting-IP",
|
||||
)
|
||||
return direct_address.is_loopback and not any(
|
||||
self.headers.get(name, "").strip() for name in proxy_headers
|
||||
)
|
||||
|
||||
def _require_local_decision_point(self) -> bool:
|
||||
if self._is_unproxied_loopback_request():
|
||||
return True
|
||||
self._json(
|
||||
{
|
||||
"error": "local_access_required",
|
||||
"message": "真实台架审批与停止权限仅在台架本机开放。",
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
return False
|
||||
|
||||
def _is_https(self) -> bool:
|
||||
if not self.auth.config.trust_proxy_headers:
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user