From aa16dd671b6fb8276579974f7e0842aa43348152 Mon Sep 17 00:00:00 2001
From: Eric Callahan <arksine.code@gmail.com>
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 <arksine.code@gmail.com>
---
 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 4c799f7..47d2fb5 100644
--- a/moonraker/app.py
+++ b/moonraker/app.py
@@ -22,7 +22,7 @@ from tornado.escape import url_unescape, url_escape
 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 @@ if TYPE_CHECKING:
     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 @@ class MoonrakerApp:
         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 @@ class MoonrakerApp:
             (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 @@ class DynamicRequestHandler(AuthorizedRequestHandler):
         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 @@ class DynamicRequestHandler(AuthorizedRequestHandler):
             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 88c1811..d0c63d3 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 @@ if TYPE_CHECKING:
     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 @@ class APIDefinition:
         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 @@ class BaseRemoteConnection(APITransport):
         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 @@ class WebRequest:
         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 @@ class WebRequest:
             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 @@ class JsonRPC:
     ) -> 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 e9ebde1..9043f86 100644
--- a/moonraker/utils/__init__.py
+++ b/moonraker/utils/__init__.py
@@ -19,6 +19,7 @@ import re
 import struct
 import socket
 import enum
+import ipaddress
 from . import source_info
 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*/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 fd69a6e..c51ffa1 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 @@ from .common import (
     BaseRemoteConnection,
     TransportType,
 )
-from .utils import ServerError
+from .utils import ServerError, parse_ip_address
 
 # Annotation imports
 from typing import (
@@ -37,7 +36,7 @@ if TYPE_CHECKING:
     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 @@ class BridgeSocket(WebSocketHandler):
         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