Files
chia-blockchain/chia/rpc/rpc_server.py
T
6050235bdb [CHIA-3043] Add (hopefully) non-controversial ruff rules (#19684)
* Add (hopefully) non-controversial ruff rules

* mypy-exclusions.txt

* PIE

* Fix pre-commit?

* PYI

* RSE

* S + ignores

* Whitespace fix

* Re-add build-init-files.py

* Add comment about security ignores

* Update mypy-exclusions.txt

Co-authored-by: Kyle Altendorf <sda@fstab.net>

* Fix the executable status of build-init-files.py

* Set executable bit for tools/run_block.py and readd shebang

* Fix new errors

---------

Co-authored-by: Kyle Altendorf <sda@fstab.net>
2025-06-11 08:36:08 -07:00

522 lines
19 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import sys
import traceback
from collections.abc import AsyncIterator, Awaitable
from dataclasses import dataclass
from pathlib import Path
from ssl import SSLContext
from types import MethodType
from typing import Any, Callable, ClassVar, Generic, Optional, TypeVar
from aiohttp import (
ClientConnectorError,
ClientSession,
ClientWebSocketResponse,
WSMsgType,
web,
)
from chia_rs.sized_ints import uint16
from typing_extensions import Protocol, final
from chia import __version__
from chia.protocols.outbound_message import NodeType
from chia.rpc.util import wrap_http_handler
from chia.server.server import (
ChiaServer,
ssl_context_for_client,
ssl_context_for_server,
)
from chia.server.ws_connection import WSChiaConnection
from chia.types.peer_info import PeerInfo
from chia.util.byte_types import hexstr_to_bytes
from chia.util.chia_logging import default_log_level, set_log_level
from chia.util.config import str2bool
from chia.util.json_util import dict_to_json_str
from chia.util.network import WebServer, resolve
from chia.util.task_referencer import create_referenced_task
from chia.util.ws_message import (
WsRpcMessage,
create_payload,
create_payload_dict,
format_response,
pong,
)
log = logging.getLogger(__name__)
max_message_size = 50 * 1024 * 1024 # 50MB
EndpointResult = dict[str, Any]
Endpoint = Callable[[dict[str, object]], Awaitable[EndpointResult]]
_T_RpcApiProtocol = TypeVar("_T_RpcApiProtocol", bound="RpcApiProtocol")
class StateChangedProtocol(Protocol):
def __call__(self, change: str, change_data: Optional[dict[str, Any]]) -> None: ...
class RpcServiceProtocol(Protocol):
_shut_down: bool
"""Indicates a request to shut down the service.
This is generally set internally by the class itself and not used externally.
Consider replacing with asyncio cancellation.
"""
@property
def server(self) -> ChiaServer:
"""The server object that handles the common server behavior for the RPC."""
# a property so as to be read only which allows ChiaServer to satisfy
# Optional[ChiaServer]
...
def get_connections(self, request_node_type: Optional[NodeType]) -> list[dict[str, Any]]:
"""Report the active connections for the service.
A default implementation is available and can be called as
chia.rpc.rpc_server.default_get_connections()
"""
...
async def on_connect(self, peer: WSChiaConnection) -> None:
"""Called when a new connection is established to the server."""
...
def _set_state_changed_callback(self, callback: StateChangedProtocol) -> None:
"""Register the callable that will process state change events."""
...
@contextlib.asynccontextmanager
async def manage(self) -> AsyncIterator[None]:
yield # pragma: no cover
class RpcApiProtocol(Protocol):
service_name: str
"""The name of the service.
All lower case with underscores as needed.
"""
def __init__(self, node: RpcServiceProtocol) -> None: ...
@property
def service(self) -> RpcServiceProtocol:
"""The service object that provides the specific behavior for the API."""
# using a read-only property per https://github.com/python/mypy/issues/12990
...
def get_routes(self) -> dict[str, Endpoint]:
"""Return the mapping of endpoints to handler callables."""
...
async def _state_changed(self, change: str, change_data: Optional[dict[str, Any]]) -> list[WsRpcMessage]:
"""Notify the state change system of a changed state."""
...
def default_get_connections(server: ChiaServer, request_node_type: Optional[NodeType]) -> list[dict[str, Any]]:
connections = server.get_connections(request_node_type)
con_info = [
{
"type": con.connection_type,
"local_port": con.local_port,
"peer_host": con.peer_info.host,
"peer_port": con.peer_info.port,
"peer_server_port": con.peer_server_port,
"node_id": con.peer_node_id,
"creation_time": con.creation_time,
"bytes_read": con.bytes_read,
"bytes_written": con.bytes_written,
"last_message_time": con.last_message_time,
}
for con in connections
]
return con_info
@final
@dataclass
class RpcServer(Generic[_T_RpcApiProtocol]):
"""
Implementation of RPC server.
"""
rpc_api: _T_RpcApiProtocol
stop_cb: Callable[[], None]
service_name: str
ssl_context: SSLContext
ssl_client_context: SSLContext
net_config: dict[str, Any]
service_config: dict[str, Any]
webserver: Optional[WebServer] = None
daemon_heartbeat: int = 300
daemon_connection_task: Optional[asyncio.Task[None]] = None
shut_down: bool = False
websocket: Optional[ClientWebSocketResponse] = None
client_session: Optional[ClientSession] = None
prefer_ipv6: bool = False
@classmethod
def create(
cls,
rpc_api: _T_RpcApiProtocol,
service_name: str,
stop_cb: Callable[[], None],
root_path: Path,
net_config: dict[str, Any],
service_config: dict[str, Any],
prefer_ipv6: bool,
) -> RpcServer[_T_RpcApiProtocol]:
crt_path = root_path / net_config["daemon_ssl"]["private_crt"]
key_path = root_path / net_config["daemon_ssl"]["private_key"]
ca_cert_path = root_path / net_config["private_ssl_ca"]["crt"]
ca_key_path = root_path / net_config["private_ssl_ca"]["key"]
daemon_heartbeat = net_config.get("daemon_heartbeat", 300)
ssl_context = ssl_context_for_server(ca_cert_path, ca_key_path, crt_path, key_path, log=log)
ssl_client_context = ssl_context_for_client(ca_cert_path, ca_key_path, crt_path, key_path, log=log)
return cls(
rpc_api,
stop_cb,
service_name,
ssl_context,
ssl_client_context,
net_config,
service_config=service_config,
daemon_heartbeat=daemon_heartbeat,
prefer_ipv6=prefer_ipv6,
)
async def start(self, self_hostname: str, rpc_port: uint16, max_request_body_size: int) -> None:
if self.webserver is not None:
raise RuntimeError("RpcServer already started")
self.webserver = await WebServer.create(
hostname=self_hostname,
port=rpc_port,
max_request_body_size=max_request_body_size,
routes=[web.post(route, wrap_http_handler(func)) for (route, func) in self._get_routes().items()],
ssl_context=self.ssl_context,
prefer_ipv6=self.prefer_ipv6,
)
def close(self) -> None:
self.shut_down = True
if self.webserver is not None:
self.webserver.close()
async def await_closed(self) -> None:
if self.websocket is not None:
await self.websocket.close()
if self.client_session is not None:
await self.client_session.close()
if self.webserver is not None:
await self.webserver.await_closed()
if self.daemon_connection_task is not None:
await self.daemon_connection_task
self.daemon_connection_task = None
async def _state_changed(self, change: str, change_data: Optional[dict[str, Any]]) -> None:
if self.websocket is None or self.websocket.closed:
return None
payloads: list[WsRpcMessage] = await self.rpc_api._state_changed(change, change_data)
if change in {"add_connection", "close_connection", "peer_changed_peak"}:
data = await self.get_connections({})
if data is not None:
payload = create_payload_dict(
"get_connections",
data,
self.service_name,
"wallet_ui",
)
payloads.append(payload)
for payload in payloads:
if "success" not in payload["data"]:
payload["data"]["success"] = True
if self.websocket is None or self.websocket.closed:
return None
try:
await self.websocket.send_str(dict_to_json_str(payload))
except Exception:
tb = traceback.format_exc()
log.warning(f"Sending data failed. Exception {tb}.")
def state_changed(self, change: str, change_data: Optional[dict[str, Any]] = None) -> None:
if self.websocket is None or self.websocket.closed:
return None
create_referenced_task(self._state_changed(change, change_data), known_unreferenced=True)
@property
def listen_port(self) -> uint16:
if self.webserver is None:
raise RuntimeError("RpcServer is not started")
return self.webserver.listen_port
def _get_routes(self) -> dict[str, Endpoint]:
return {
**self.rpc_api.get_routes(),
**{path: MethodType(handler, self) for path, handler in self._routes.items()},
}
async def get_routes(self, request: dict[str, Any]) -> EndpointResult:
return {
"success": True,
"routes": list(self._get_routes().keys()),
}
async def get_network_info(self, _: dict[str, Any]) -> EndpointResult:
network_name = self.net_config["selected_network"]
address_prefix = self.net_config["network_overrides"]["config"][network_name]["address_prefix"]
genesis_challenge = self.net_config["network_overrides"]["constants"][network_name]["GENESIS_CHALLENGE"]
return {
"network_name": network_name,
"network_prefix": address_prefix,
"genesis_challenge": genesis_challenge,
}
async def get_connections(self, request: dict[str, Any]) -> EndpointResult:
request_node_type: Optional[NodeType] = None
if "node_type" in request:
request_node_type = NodeType(request["node_type"])
if self.rpc_api.service.server is None:
raise ValueError("Global connections is not set")
con_info: list[dict[str, Any]]
con_info = self.rpc_api.service.get_connections(request_node_type=request_node_type)
return {"connections": con_info}
async def open_connection(self, request: dict[str, Any]) -> EndpointResult:
host = request["host"]
port = request["port"]
target_node: PeerInfo = PeerInfo(await resolve(host, prefer_ipv6=self.prefer_ipv6), uint16(int(port)))
on_connect = None
if hasattr(self.rpc_api.service, "on_connect"):
on_connect = self.rpc_api.service.on_connect
if not await self.rpc_api.service.server.start_client(target_node, on_connect):
return {"success": False, "error": f"could not connect to {target_node}"}
return {"success": True}
async def close_connection(self, request: dict[str, Any]) -> EndpointResult:
node_id = hexstr_to_bytes(request["node_id"])
if self.rpc_api.service.server is None:
raise web.HTTPInternalServerError
connections_to_close = [c for c in self.rpc_api.service.server.get_connections() if c.peer_node_id == node_id]
if len(connections_to_close) == 0:
raise ValueError(f"Connection with node_id {node_id.hex()} does not exist")
for connection in connections_to_close:
await connection.close()
return {}
async def stop_node(self, request: dict[str, Any]) -> EndpointResult:
"""
Shuts down the node.
"""
if self.stop_cb is not None:
self.stop_cb()
return {}
async def healthz(self, request: dict[str, Any]) -> EndpointResult:
return {
"success": True,
}
async def get_version(self, request: dict[str, Any]) -> EndpointResult:
return {
"version": __version__,
}
async def get_log_level(self, request: dict[str, Any]) -> EndpointResult:
logger = logging.getLogger()
level_number = logger.level
level_name = logging.getLevelName(level_number)
if sys.version_info >= (3, 11):
map = logging.getLevelNamesMapping()
else:
map = logging._nameToLevel
return {
"success": True,
"level": level_name,
"available_levels": list(map),
}
async def reset_log_level(self, request: dict[str, Any]) -> EndpointResult:
level_name = self.service_config.get("log_level", default_log_level)
return await self.set_log_level(request={"level": level_name})
async def set_log_level(self, request: dict[str, Any]) -> EndpointResult:
error_strings = set_log_level(log_level=request["level"], service_name=self.service_name)
status = await self.get_log_level(request={})
status["success"] &= len(error_strings) == 0
return {
**status,
"errors": error_strings,
}
async def ws_api(self, message: WsRpcMessage) -> Optional[dict[str, object]]:
"""
This function gets called when new message is received via websocket.
"""
command = message["command"]
if message["ack"]:
return None
data: dict[str, object] = {}
if "data" in message:
data = message["data"]
if command == "ping":
return pong()
f_internal: Optional[Endpoint] = getattr(self, command, None)
if f_internal is not None:
return await f_internal(data)
f_rpc_api: Optional[Endpoint] = getattr(self.rpc_api, command, None)
if f_rpc_api is not None:
return await f_rpc_api(data)
raise ValueError(f"unknown_command {command}")
async def safe_handle(self, websocket: ClientWebSocketResponse, payload: str) -> None:
message = None
try:
message = json.loads(payload)
log.debug(f"Rpc call <- {message['command']}")
response = await self.ws_api(message)
# Only respond if we return something from api call
if response is not None:
log.debug(f"Rpc response -> {message['command']}")
# Set success to true automatically (unless it's already set)
if "success" not in response:
response["success"] = True
await websocket.send_str(format_response(message, response))
except Exception as e:
tb = traceback.format_exc()
log.warning(f"Error while handling message: {tb}")
if message is not None:
error = e.args[0] if e.args else e
res = {"success": False, "error": f"{error}"}
await websocket.send_str(format_response(message, res))
async def connection(self, ws: ClientWebSocketResponse) -> None:
data = {"service": self.service_name}
payload = create_payload("register_service", data, self.service_name, "daemon")
await ws.send_str(payload)
while True:
# ClientWebSocketReponse::receive() internally handles PING, PONG, and CLOSE messages
msg = await ws.receive()
if msg.type == WSMsgType.TEXT:
message = msg.data.strip()
# log.info(f"received message: {message}")
await self.safe_handle(ws, message)
elif msg.type == WSMsgType.BINARY:
log.debug("Received binary data")
else:
if msg.type == WSMsgType.ERROR:
log.error("Error during receive %s", ws.exception())
elif msg.type == WSMsgType.CLOSED:
pass
break
def connect_to_daemon(self, self_hostname: str, daemon_port: uint16) -> None:
if self.daemon_connection_task is not None:
raise RuntimeError("Already connected to the daemon")
async def inner() -> None:
while not self.shut_down:
try:
self.client_session = ClientSession()
self.websocket = await self.client_session.ws_connect(
f"wss://{self_hostname}:{daemon_port}",
autoclose=True,
autoping=True,
heartbeat=self.daemon_heartbeat,
ssl=self.ssl_client_context,
max_msg_size=max_message_size,
)
await self.connection(self.websocket)
except ClientConnectorError:
log.warning(f"Cannot connect to daemon at ws://{self_hostname}:{daemon_port}")
except Exception as e:
tb = traceback.format_exc()
log.warning(f"Exception: {tb} {type(e)}")
if self.websocket is not None:
await self.websocket.close()
if self.client_session is not None:
await self.client_session.close()
self.websocket = None
self.client_session = None
await asyncio.sleep(2)
self.daemon_connection_task = create_referenced_task(inner())
_routes: ClassVar[dict[str, Callable[..., Awaitable[object]]]] = {
"/get_network_info": get_network_info,
"/get_connections": get_connections,
"/open_connection": open_connection,
"/close_connection": close_connection,
"/stop_node": stop_node,
"/get_routes": get_routes,
"/get_version": get_version,
"/healthz": healthz,
"/get_log_level": get_log_level,
"/set_log_level": set_log_level,
"/reset_log_level": reset_log_level,
}
async def start_rpc_server(
rpc_api: _T_RpcApiProtocol,
self_hostname: str,
daemon_port: uint16,
rpc_port: uint16,
stop_cb: Callable[[], None],
root_path: Path,
net_config: dict[str, object],
service_config: dict[str, object],
connect_to_daemon: bool = True,
max_request_body_size: Optional[int] = None,
) -> RpcServer[_T_RpcApiProtocol]:
"""
Starts an HTTP server with the following RPC methods, to be used by local clients to
query the node.
"""
try:
if max_request_body_size is None:
max_request_body_size = 1024**2
prefer_ipv6 = str2bool(str(net_config.get("prefer_ipv6", False)))
rpc_server = RpcServer.create(
rpc_api,
rpc_api.service_name,
stop_cb,
root_path,
net_config,
service_config=service_config,
prefer_ipv6=prefer_ipv6,
)
rpc_server.rpc_api.service._set_state_changed_callback(rpc_server.state_changed)
await rpc_server.start(self_hostname, rpc_port, max_request_body_size)
if connect_to_daemon:
rpc_server.connect_to_daemon(self_hostname, daemon_port)
return rpc_server
except Exception:
tb = traceback.format_exc()
log.error(f"Starting RPC server failed. Exception {tb}")
raise