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 <arksine.code@gmail.com>
This commit is contained in:
Eric Callahan 2023-11-28 10:23:56 -05:00
parent 6dbcdf537f
commit aa16dd671b
No known key found for this signature in database
GPG Key ID: 5A1EB336DFB4C71B
4 changed files with 89 additions and 27 deletions

View File

@ -22,7 +22,7 @@ from tornado.escape import url_unescape, url_escape
from tornado.routing import Rule, PathMatches, AnyMatches from tornado.routing import Rule, PathMatches, AnyMatches
from tornado.http1connection import HTTP1Connection from tornado.http1connection import HTTP1Connection
from tornado.log import access_log from tornado.log import access_log
from .utils import ServerError, source_info from .utils import ServerError, source_info, parse_ip_address
from .common import ( from .common import (
JsonRPC, JsonRPC,
WebRequest, WebRequest,
@ -59,6 +59,7 @@ if TYPE_CHECKING:
from .eventloop import EventLoop from .eventloop import EventLoop
from .confighelper import ConfigHelper from .confighelper import ConfigHelper
from .klippy_connection import KlippyConnection as Klippy from .klippy_connection import KlippyConnection as Klippy
from .utils import IPAddress
from .components.file_manager.file_manager import FileManager from .components.file_manager.file_manager import FileManager
from .components.announcements import Announcements from .components.announcements import Announcements
from .components.machine import Machine from .components.machine import Machine
@ -144,7 +145,10 @@ class MoonrakerApp:
self.http_server: Optional[HTTPServer] = None self.http_server: Optional[HTTPServer] = None
self.secure_server: Optional[HTTPServer] = None self.secure_server: Optional[HTTPServer] = None
self.template_cache: Dict[str, JinjaTemplate] = {} 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 = config.getint('max_upload_size', 1024)
self.max_upload_size *= 1024 * 1024 self.max_upload_size *= 1024 * 1024
max_ws_conns = config.getint( max_ws_conns = config.getint(
@ -197,7 +201,8 @@ class MoonrakerApp:
(home_pattern, WelcomeHandler), (home_pattern, WelcomeHandler),
(f"{self._route_prefix}/websocket", WebSocket), (f"{self._route_prefix}/websocket", WebSocket),
(f"{self._route_prefix}/klippysocket", BridgeSocket), (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.app = tornado.web.Application(app_handlers, **app_args)
self.get_handler_delegate = self.app.get_handler_delegate self.get_handler_delegate = self.app.get_handler_delegate
@ -632,7 +637,7 @@ class DynamicRequestHandler(AuthorizedRequestHandler):
req = f"{self.request.method} {self.request.path}" req = f"{self.request.method} {self.request.path}"
self._log_debug(f"HTTP Request::{req}", args) self._log_debug(f"HTTP Request::{req}", args)
try: try:
ip = self.request.remote_ip or "" ip = parse_ip_address(self.request.remote_ip or "")
result = await self.api_defintion.request( result = await self.api_defintion.request(
args, req_type, transport, ip, self.current_user args, req_type, transport, ip, self.current_user
) )
@ -651,6 +656,50 @@ class DynamicRequestHandler(AuthorizedRequestHandler):
self.set_header("Content-Type", self.content_type) self.set_header("Content-Type", self.content_type)
self.finish(result) 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): class FileRequestHandler(AuthorizedFileHandler):
def set_extra_headers(self, path: str) -> None: def set_extra_headers(self, path: str) -> None:
# The call below shold never return an empty string, # The call below shold never return an empty string,

View File

@ -6,7 +6,6 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import ipaddress
import logging import logging
import copy import copy
import re import re
@ -36,11 +35,11 @@ if TYPE_CHECKING:
from .server import Server from .server import Server
from .websockets import WebsocketManager from .websockets import WebsocketManager
from .components.authorization import Authorization from .components.authorization import Authorization
from .utils import IPAddress
from asyncio import Future from asyncio import Future
_T = TypeVar("_T") _T = TypeVar("_T")
_C = TypeVar("_C", str, bool, float, int) _C = TypeVar("_C", str, bool, float, int)
_F = TypeVar("_F", bound="ExtendedFlag") _F = TypeVar("_F", bound="ExtendedFlag")
IPUnion = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
ConvType = Union[str, bool, float, int] ConvType = Union[str, bool, float, int]
ArgVal = Union[None, int, float, bool, str] ArgVal = Union[None, int, float, bool, str]
RPCCallback = Callable[..., Coroutine] RPCCallback = Callable[..., Coroutine]
@ -188,7 +187,7 @@ class APIDefinition:
args: Dict[str, Any], args: Dict[str, Any],
request_type: RequestType, request_type: RequestType,
transport: Optional[APITransport] = None, transport: Optional[APITransport] = None,
ip_addr: str = "", ip_addr: Optional[IPAddress] = None,
user: Optional[Dict[str, Any]] = None user: Optional[Dict[str, Any]] = None
) -> Coroutine: ) -> Coroutine:
return self.callback( return self.callback(
@ -271,6 +270,14 @@ class APITransport:
def transport_type(self) -> TransportType: def transport_type(self) -> TransportType:
return TransportType.INTERNAL 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( def screen_rpc_request(
self, api_def: APIDefinition, req_type: RequestType, args: Dict[str, Any] self, api_def: APIDefinition, req_type: RequestType, args: Dict[str, Any]
) -> None: ) -> None:
@ -288,7 +295,6 @@ class BaseRemoteConnection(APITransport):
self.wsm: WebsocketManager = self.server.lookup_component("websockets") self.wsm: WebsocketManager = self.server.lookup_component("websockets")
self.rpc: JsonRPC = self.server.lookup_component("jsonrpc") self.rpc: JsonRPC = self.server.lookup_component("jsonrpc")
self._uid = id(self) self._uid = id(self)
self.ip_addr = ""
self.is_closed: bool = False self.is_closed: bool = False
self.queue_busy: bool = False self.queue_busy: bool = False
self.pending_responses: Dict[int, Future] = {} self.pending_responses: Dict[int, Future] = {}
@ -480,18 +486,14 @@ class WebRequest:
args: Dict[str, Any], args: Dict[str, Any],
request_type: RequestType = RequestType(0), request_type: RequestType = RequestType(0),
transport: Optional[APITransport] = None, transport: Optional[APITransport] = None,
ip_addr: str = "", ip_addr: Optional[IPAddress] = None,
user: Optional[Dict[str, Any]] = None user: Optional[Dict[str, Any]] = None
) -> None: ) -> None:
self.endpoint = endpoint self.endpoint = endpoint
self.args = args self.args = args
self.transport = transport self.transport = transport
self.request_type = request_type self.request_type = request_type
self.ip_addr: Optional[IPUnion] = None self.ip_addr: Optional[IPAddress] = ip_addr
try:
self.ip_addr = ipaddress.ip_address(ip_addr)
except Exception:
self.ip_addr = None
self.current_user = user self.current_user = user
def get_endpoint(self) -> str: def get_endpoint(self) -> str:
@ -514,7 +516,7 @@ class WebRequest:
return self.transport return self.transport
return None return None
def get_ip_address(self) -> Optional[IPUnion]: def get_ip_address(self) -> Optional[IPAddress]:
return self.ip_addr return self.ip_addr
def get_current_user(self) -> Optional[Dict[str, Any]]: def get_current_user(self) -> Optional[Dict[str, Any]]:
@ -797,13 +799,9 @@ class JsonRPC:
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
try: try:
transport.screen_rpc_request(api_definition, request_type, params) transport.screen_rpc_request(api_definition, request_type, params)
if isinstance(transport, BaseRemoteConnection): result = await api_definition.request(
result = await api_definition.request( params, request_type, transport, transport.ip_addr, transport.user_info
params, request_type, transport, transport.ip_addr, )
transport.user_info
)
else:
result = await api_definition.request(params, request_type, transport)
except TypeError as e: except TypeError as e:
return self.build_error( return self.build_error(
-32602, f"Invalid params:\n{e}", req_id, True, method_name -32602, f"Invalid params:\n{e}", req_id, True, method_name

View File

@ -19,6 +19,7 @@ import re
import struct import struct
import socket import socket
import enum import enum
import ipaddress
from . import source_info from . import source_info
from . import json_wrapper from . import json_wrapper
@ -39,6 +40,7 @@ if TYPE_CHECKING:
SYS_MOD_PATHS = glob.glob("/usr/lib/python3*/dist-packages") SYS_MOD_PATHS = glob.glob("/usr/lib/python3*/dist-packages")
SYS_MOD_PATHS += glob.glob("/usr/lib/python3*/site-packages") SYS_MOD_PATHS += glob.glob("/usr/lib/python3*/site-packages")
IPAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
class ServerError(Exception): class ServerError(Exception):
def __init__(self, message: str, status_code: int = 400) -> None: def __init__(self, message: str, status_code: int = 400) -> None:
@ -264,3 +266,9 @@ def pretty_print_time(seconds: int) -> str:
continue continue
fmt_list.append(f"{val} {ident}" if val == 1 else f"{val} {ident}s") fmt_list.append(f"{val} {ident}" if val == 1 else f"{val} {ident}s")
return ", ".join(fmt_list) return ", ".join(fmt_list)
def parse_ip_address(address: str) -> Optional[IPAddress]:
try:
return ipaddress.ip_address(address)
except Exception:
return None

View File

@ -6,7 +6,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import ipaddress
import asyncio import asyncio
from tornado.websocket import WebSocketHandler, WebSocketClosedError from tornado.websocket import WebSocketHandler, WebSocketClosedError
from tornado.web import HTTPError from tornado.web import HTTPError
@ -16,7 +15,7 @@ from .common import (
BaseRemoteConnection, BaseRemoteConnection,
TransportType, TransportType,
) )
from .utils import ServerError from .utils import ServerError, parse_ip_address
# Annotation imports # Annotation imports
from typing import ( from typing import (
@ -37,7 +36,7 @@ if TYPE_CHECKING:
from .confighelper import ConfigHelper from .confighelper import ConfigHelper
from .components.extensions import ExtensionManager from .components.extensions import ExtensionManager
from .components.authorization import Authorization from .components.authorization import Authorization
IPUnion = Union[ipaddress.IPv4Address, ipaddress.IPv6Address] from .utils import IPAddress
ConvType = Union[str, bool, float, int] ConvType = Union[str, bool, float, int]
ArgVal = Union[None, int, float, bool, str] ArgVal = Union[None, int, float, bool, str]
RPCCallback = Callable[..., Coroutine] RPCCallback = Callable[..., Coroutine]
@ -231,9 +230,13 @@ class WebSocket(WebSocketHandler, BaseRemoteConnection):
def initialize(self) -> None: def initialize(self) -> None:
self.on_create(self.settings['server']) 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() self.last_pong_time: float = self.eventloop.get_loop_time()
@property
def ip_addr(self) -> Optional[IPAddress]:
return self._ip_addr
@property @property
def hostname(self) -> str: def hostname(self) -> str:
return self.request.host_name return self.request.host_name
@ -336,13 +339,17 @@ class BridgeSocket(WebSocketHandler):
self.wsm: WebsocketManager = self.server.lookup_component("websockets") self.wsm: WebsocketManager = self.server.lookup_component("websockets")
self.eventloop = self.server.get_event_loop() self.eventloop = self.server.get_event_loop()
self.uid = id(self) 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.last_pong_time: float = self.eventloop.get_loop_time()
self.is_closed = False self.is_closed = False
self.klippy_writer: Optional[asyncio.StreamWriter] = None self.klippy_writer: Optional[asyncio.StreamWriter] = None
self.klippy_write_buf: List[bytes] = [] self.klippy_write_buf: List[bytes] = []
self.klippy_queue_busy: bool = False self.klippy_queue_busy: bool = False
@property
def ip_addr(self) -> Optional[IPAddress]:
return self._ip_addr
@property @property
def hostname(self) -> str: def hostname(self) -> str:
return self.request.host_name return self.request.host_name