From 388def910d7d613da1a2c94ad2eb0de0fff17a35 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Tue, 28 Nov 2023 10:23:56 -0500 Subject: [PATCH] app: add jsonrpc HTTP endpoint Add a POST /server/jsonrpc endpoint that processes jsonrpc requests from the body. This allows developers familiar with the JSON-RPC API to use it in places where a websocket is not desiriable. Signed-off-by: Eric Callahan --- moonraker/app.py | 57 ++++++++++++++++++++++++++++++++++--- moonraker/common.py | 34 +++++++++++----------- moonraker/utils/__init__.py | 8 ++++++ moonraker/websockets.py | 17 +++++++---- 4 files changed, 89 insertions(+), 27 deletions(-) diff --git a/moonraker/app.py b/moonraker/app.py index 4c799f71c..47d2fb52a 100644 --- a/moonraker/app.py +++ b/moonraker/app.py @@ -22,7 +22,7 @@ from tornado.routing import Rule, PathMatches, AnyMatches from tornado.http1connection import HTTP1Connection from tornado.log import access_log -from .utils import ServerError, source_info +from .utils import ServerError, source_info, parse_ip_address from .common import ( JsonRPC, WebRequest, @@ -59,6 +59,7 @@ from .eventloop import EventLoop from .confighelper import ConfigHelper from .klippy_connection import KlippyConnection as Klippy + from .utils import IPAddress from .components.file_manager.file_manager import FileManager from .components.announcements import Announcements from .components.machine import Machine @@ -144,7 +145,10 @@ def __init__(self, config: ConfigHelper) -> None: self.http_server: Optional[HTTPServer] = None self.secure_server: Optional[HTTPServer] = None self.template_cache: Dict[str, JinjaTemplate] = {} - self.registered_base_handlers: List[str] = [] + self.registered_base_handlers: List[str] = [ + "/server/redirect", + "/server/jsonrpc" + ] self.max_upload_size = config.getint('max_upload_size', 1024) self.max_upload_size *= 1024 * 1024 max_ws_conns = config.getint( @@ -197,7 +201,8 @@ def __init__(self, config: ConfigHelper) -> None: (home_pattern, WelcomeHandler), (f"{self._route_prefix}/websocket", WebSocket), (f"{self._route_prefix}/klippysocket", BridgeSocket), - (f"{self._route_prefix}/server/redirect", RedirectHandler) + (f"{self._route_prefix}/server/redirect", RedirectHandler), + (f"{self._route_prefix}/server/jsonrpc", RPCHandler) ] self.app = tornado.web.Application(app_handlers, **app_args) self.get_handler_delegate = self.app.get_handler_delegate @@ -632,7 +637,7 @@ async def _process_http_request(self, req_type: RequestType) -> None: req = f"{self.request.method} {self.request.path}" self._log_debug(f"HTTP Request::{req}", args) try: - ip = self.request.remote_ip or "" + ip = parse_ip_address(self.request.remote_ip or "") result = await self.api_defintion.request( args, req_type, transport, ip, self.current_user ) @@ -651,6 +656,50 @@ async def _process_http_request(self, req_type: RequestType) -> None: self.set_header("Content-Type", self.content_type) self.finish(result) +class RPCHandler(AuthorizedRequestHandler, APITransport): + def initialize(self) -> None: + super(RPCHandler, self).initialize() + self.auth_required = False + + @property + def transport_type(self) -> TransportType: + return TransportType.HTTP + + @property + def user_info(self) -> Optional[Dict[str, Any]]: + return self.current_user + + @property + def ip_addr(self) -> Optional[IPAddress]: + return parse_ip_address(self.request.remote_ip or "") + + def screen_rpc_request( + self, api_def: APIDefinition, req_type: RequestType, args: Dict[str, Any] + ) -> None: + if self.current_user is None and api_def.auth_required: + raise self.server.error("Unauthorized", 401) + if api_def.endpoint == "objects/subscribe": + raise self.server.error( + "Subscriptions not available for HTTP transport", 404 + ) + + def send_status(self, status: Dict[str, Any], eventtime: float) -> None: + # Can't handle status updates. This should not be called, but + # we don't want to raise an exception if it is + pass + + async def post(self, *args, **kwargs) -> None: + content_type = self.request.headers.get('Content-Type', "").strip() + if not content_type.startswith("application/json"): + raise tornado.web.HTTPError( + 400, "Invalid content type, application/json required" + ) + rpc: JsonRPC = self.server.lookup_component("jsonrpc") + result = await rpc.dispatch(self.request.body, self) + if result is not None: + self.set_header("Content-Type", "application/json; charset=UTF-8") + self.finish(result) + class FileRequestHandler(AuthorizedFileHandler): def set_extra_headers(self, path: str) -> None: # The call below shold never return an empty string, diff --git a/moonraker/common.py b/moonraker/common.py index 88c1811bd..d0c63d395 100644 --- a/moonraker/common.py +++ b/moonraker/common.py @@ -6,7 +6,6 @@ from __future__ import annotations import sys -import ipaddress import logging import copy import re @@ -36,11 +35,11 @@ from .server import Server from .websockets import WebsocketManager from .components.authorization import Authorization + from .utils import IPAddress from asyncio import Future _T = TypeVar("_T") _C = TypeVar("_C", str, bool, float, int) _F = TypeVar("_F", bound="ExtendedFlag") - IPUnion = Union[ipaddress.IPv4Address, ipaddress.IPv6Address] ConvType = Union[str, bool, float, int] ArgVal = Union[None, int, float, bool, str] RPCCallback = Callable[..., Coroutine] @@ -188,7 +187,7 @@ def request( args: Dict[str, Any], request_type: RequestType, transport: Optional[APITransport] = None, - ip_addr: str = "", + ip_addr: Optional[IPAddress] = None, user: Optional[Dict[str, Any]] = None ) -> Coroutine: return self.callback( @@ -271,6 +270,14 @@ class APITransport: def transport_type(self) -> TransportType: return TransportType.INTERNAL + @property + def user_info(self) -> Optional[Dict[str, Any]]: + return None + + @property + def ip_addr(self) -> Optional[IPAddress]: + return None + def screen_rpc_request( self, api_def: APIDefinition, req_type: RequestType, args: Dict[str, Any] ) -> None: @@ -288,7 +295,6 @@ def on_create(self, server: Server) -> None: self.wsm: WebsocketManager = self.server.lookup_component("websockets") self.rpc: JsonRPC = self.server.lookup_component("jsonrpc") self._uid = id(self) - self.ip_addr = "" self.is_closed: bool = False self.queue_busy: bool = False self.pending_responses: Dict[int, Future] = {} @@ -480,18 +486,14 @@ def __init__( args: Dict[str, Any], request_type: RequestType = RequestType(0), transport: Optional[APITransport] = None, - ip_addr: str = "", + ip_addr: Optional[IPAddress] = None, user: Optional[Dict[str, Any]] = None ) -> None: self.endpoint = endpoint self.args = args self.transport = transport self.request_type = request_type - self.ip_addr: Optional[IPUnion] = None - try: - self.ip_addr = ipaddress.ip_address(ip_addr) - except Exception: - self.ip_addr = None + self.ip_addr: Optional[IPAddress] = ip_addr self.current_user = user def get_endpoint(self) -> str: @@ -514,7 +516,7 @@ def get_client_connection(self) -> Optional[BaseRemoteConnection]: return self.transport return None - def get_ip_address(self) -> Optional[IPUnion]: + def get_ip_address(self) -> Optional[IPAddress]: return self.ip_addr def get_current_user(self) -> Optional[Dict[str, Any]]: @@ -797,13 +799,9 @@ async def execute_method( ) -> Optional[Dict[str, Any]]: try: transport.screen_rpc_request(api_definition, request_type, params) - if isinstance(transport, BaseRemoteConnection): - result = await api_definition.request( - params, request_type, transport, transport.ip_addr, - transport.user_info - ) - else: - result = await api_definition.request(params, request_type, transport) + result = await api_definition.request( + params, request_type, transport, transport.ip_addr, transport.user_info + ) except TypeError as e: return self.build_error( -32602, f"Invalid params:\n{e}", req_id, True, method_name diff --git a/moonraker/utils/__init__.py b/moonraker/utils/__init__.py index e9ebde1bf..9043f8656 100644 --- a/moonraker/utils/__init__.py +++ b/moonraker/utils/__init__.py @@ -19,6 +19,7 @@ import struct import socket import enum +import ipaddress from . import source_info from . import json_wrapper @@ -39,6 +40,7 @@ SYS_MOD_PATHS = glob.glob("/usr/lib/python3*/dist-packages") SYS_MOD_PATHS += glob.glob("/usr/lib/python3*/site-packages") +IPAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address] class ServerError(Exception): def __init__(self, message: str, status_code: int = 400) -> None: @@ -264,3 +266,9 @@ def pretty_print_time(seconds: int) -> str: continue fmt_list.append(f"{val} {ident}" if val == 1 else f"{val} {ident}s") return ", ".join(fmt_list) + +def parse_ip_address(address: str) -> Optional[IPAddress]: + try: + return ipaddress.ip_address(address) + except Exception: + return None diff --git a/moonraker/websockets.py b/moonraker/websockets.py index fd69a6e81..c51ffa130 100644 --- a/moonraker/websockets.py +++ b/moonraker/websockets.py @@ -6,7 +6,6 @@ from __future__ import annotations import logging -import ipaddress import asyncio from tornado.websocket import WebSocketHandler, WebSocketClosedError from tornado.web import HTTPError @@ -16,7 +15,7 @@ BaseRemoteConnection, TransportType, ) -from .utils import ServerError +from .utils import ServerError, parse_ip_address # Annotation imports from typing import ( @@ -37,7 +36,7 @@ from .confighelper import ConfigHelper from .components.extensions import ExtensionManager from .components.authorization import Authorization - IPUnion = Union[ipaddress.IPv4Address, ipaddress.IPv6Address] + from .utils import IPAddress ConvType = Union[str, bool, float, int] ArgVal = Union[None, int, float, bool, str] RPCCallback = Callable[..., Coroutine] @@ -231,9 +230,13 @@ class WebSocket(WebSocketHandler, BaseRemoteConnection): def initialize(self) -> None: self.on_create(self.settings['server']) - self.ip_addr: str = self.request.remote_ip or "" + self._ip_addr = parse_ip_address(self.request.remote_ip or "") self.last_pong_time: float = self.eventloop.get_loop_time() + @property + def ip_addr(self) -> Optional[IPAddress]: + return self._ip_addr + @property def hostname(self) -> str: return self.request.host_name @@ -336,13 +339,17 @@ def initialize(self) -> None: self.wsm: WebsocketManager = self.server.lookup_component("websockets") self.eventloop = self.server.get_event_loop() self.uid = id(self) - self.ip_addr: str = self.request.remote_ip or "" + self._ip_addr = parse_ip_address(self.request.remote_ip or "") self.last_pong_time: float = self.eventloop.get_loop_time() self.is_closed = False self.klippy_writer: Optional[asyncio.StreamWriter] = None self.klippy_write_buf: List[bytes] = [] self.klippy_queue_busy: bool = False + @property + def ip_addr(self) -> Optional[IPAddress]: + return self._ip_addr + @property def hostname(self) -> str: return self.request.host_name