From 1ad0e94b640d16a8d832287362e1785c78d3ec49 Mon Sep 17 00:00:00 2001 From: ced-b Date: Wed, 2 Nov 2022 12:44:12 -0400 Subject: [PATCH] Exposing Signal CLI as HTTP Server (#1078) * Add initial proof of concept for http server * Add support for registration commands * Add support for MultiLocalCommands * Improve handling of HTTP responses Makes it so that responses area all uniformly JSON and wrapped into the proper response envelope. * Add caching for workflows * Run http server with daemon command This fits the existing command line API better * Wrap the existing JSON RPC handler in HTTP Service This is a redesign of earlier attempts to make an HTTP service. Fixing that service turned out that it would have to be a copy of the SignalJsonRpcDispatcherHandler. So instead of copy pasting all the code the existing service is simply being wrapped. * Switch http server to use command handler * Clean up and simplification * Pass full InetSocketAddress * Minor fixes and improvements Based on code review. Co-authored-by: cedb --- .../asamk/signal/commands/DaemonCommand.java | 25 ++++ .../asamk/signal/http/HttpServerHandler.java | 110 ++++++++++++++++++ .../jsonrpc/SignalJsonRpcCommandHandler.java | 2 +- 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/asamk/signal/http/HttpServerHandler.java diff --git a/src/main/java/org/asamk/signal/commands/DaemonCommand.java b/src/main/java/org/asamk/signal/commands/DaemonCommand.java index 01d0326f..bd6b9386 100644 --- a/src/main/java/org/asamk/signal/commands/DaemonCommand.java +++ b/src/main/java/org/asamk/signal/commands/DaemonCommand.java @@ -13,6 +13,7 @@ import org.asamk.signal.commands.exceptions.UnexpectedErrorException; import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.dbus.DbusSignalControlImpl; import org.asamk.signal.dbus.DbusSignalImpl; +import org.asamk.signal.http.HttpServerHandler; import org.asamk.signal.json.JsonReceiveMessageHandler; import org.asamk.signal.jsonrpc.SignalJsonRpcDispatcherHandler; import org.asamk.signal.manager.Manager; @@ -69,6 +70,10 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand { .nargs("?") .setConst("localhost:7583") .help("Expose a JSON-RPC interface on a TCP socket (default localhost:7583)."); + subparser.addArgument("--http") + .nargs("?") + .setConst("localhost:8080") + .help("Expose a JSON-RPC interface as http endpoint."); subparser.addArgument("--no-receive-stdout") .help("Don’t print received messages to stdout.") .action(Arguments.storeTrue()); @@ -128,6 +133,16 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand { final var serverChannel = IOUtils.bindSocket(address); runSocketSingleAccount(m, serverChannel, receiveMode == ReceiveMode.MANUAL); } + final var httpAddress = ns.getString("http"); + if (httpAddress != null) { + final var address = IOUtils.parseInetSocketAddress(httpAddress); + final var handler = new HttpServerHandler(address, m); + try { + handler.init(); + } catch (IOException ex) { + throw new IOErrorException("Failed to initialize HTTP Server", ex); + } + } final var isDbusSystem = Boolean.TRUE.equals(ns.getBoolean("dbus-system")); if (isDbusSystem) { runDbusSingleAccount(m, true, receiveMode != ReceiveMode.ON_START); @@ -199,6 +214,16 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand { final var serverChannel = IOUtils.bindSocket(address); runSocketMultiAccount(c, serverChannel, receiveMode == ReceiveMode.MANUAL); } + final var httpAddress = ns.getString("http"); + if (httpAddress != null) { + final var address = IOUtils.parseInetSocketAddress(httpAddress); + final var handler = new HttpServerHandler(address, c); + try { + handler.init(); + } catch (IOException ex) { + throw new IOErrorException("Failed to initialize HTTP Server", ex); + } + } final var isDbusSystem = Boolean.TRUE.equals(ns.getBoolean("dbus-system")); if (isDbusSystem) { runDbusMultiAccount(c, receiveMode != ReceiveMode.ON_START, true); diff --git a/src/main/java/org/asamk/signal/http/HttpServerHandler.java b/src/main/java/org/asamk/signal/http/HttpServerHandler.java new file mode 100644 index 00000000..b2544b25 --- /dev/null +++ b/src/main/java/org/asamk/signal/http/HttpServerHandler.java @@ -0,0 +1,110 @@ +package org.asamk.signal.http; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import org.asamk.signal.commands.Commands; +import org.asamk.signal.jsonrpc.JsonRpcReader; +import org.asamk.signal.jsonrpc.JsonRpcResponse; +import org.asamk.signal.jsonrpc.JsonRpcSender; +import org.asamk.signal.jsonrpc.SignalJsonRpcCommandHandler; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.MultiAccountManager; +import org.asamk.signal.util.Util; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.concurrent.Executors; + +public class HttpServerHandler { + + private final static Logger logger = LoggerFactory.getLogger(HttpServerHandler.class); + + private final ObjectMapper objectMapper = Util.createJsonObjectMapper(); + + private final InetSocketAddress address; + + private final SignalJsonRpcCommandHandler commandHandler; + + public HttpServerHandler(final InetSocketAddress address, final Manager m) { + this.address = address; + commandHandler = new SignalJsonRpcCommandHandler(m, Commands::getCommand); + } + + public HttpServerHandler(final InetSocketAddress address, final MultiAccountManager c) { + this.address = address; + commandHandler = new SignalJsonRpcCommandHandler(c, Commands::getCommand); + } + + public void init() throws IOException { + + logger.info("Starting server on " + address.toString()); + + final var server = HttpServer.create(address, 0); + server.setExecutor(Executors.newFixedThreadPool(10)); + + server.createContext("/api/v1/rpc", httpExchange -> { + + if (!"POST".equals(httpExchange.getRequestMethod())) { + sendResponse(405, null, httpExchange); + return; + } + + if (!"application/json".equals(httpExchange.getRequestHeaders().getFirst("Content-Type"))) { + sendResponse(415, null, httpExchange); + return; + } + + try { + + final Object[] result = {null}; + final var jsonRpcSender = new JsonRpcSender(s -> { + if (result[0] != null) { + throw new AssertionError("There should only be a single JSON-RPC response"); + } + + result[0] = s; + }); + + final var jsonRpcReader = new JsonRpcReader(jsonRpcSender, httpExchange.getRequestBody()); + jsonRpcReader.readMessages((method, params) -> commandHandler.handleRequest(objectMapper, method, params), + response -> logger.debug("Received unexpected response for id {}", response.getId())); + + if (result[0] !=null) { + sendResponse(200, result[0], httpExchange); + } else { + sendResponse(201, null, httpExchange); + } + + } + catch (Throwable aEx) { + logger.error("Failed to process request.", aEx); + sendResponse(200, JsonRpcResponse.forError( + new JsonRpcResponse.Error(JsonRpcResponse.Error.INTERNAL_ERROR, + "An internal server error has occurred.", null), null), httpExchange); + } + }); + + server.start(); + + } + + private void sendResponse(int status, Object response, HttpExchange httpExchange) throws IOException { + if (response != null) { + final var byteResponse = objectMapper.writeValueAsBytes(response); + + httpExchange.getResponseHeaders().add("Content-Type", "application/json"); + httpExchange.sendResponseHeaders(status, byteResponse.length); + + httpExchange.getResponseBody().write(byteResponse); + } else { + httpExchange.sendResponseHeaders(status, 0); + } + + httpExchange.getResponseBody().close(); + } + +} diff --git a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java index 0f6a00bd..2249fad6 100644 --- a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java +++ b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java @@ -52,7 +52,7 @@ public class SignalJsonRpcCommandHandler { this.commandProvider = commandProvider; } - JsonNode handleRequest( + public JsonNode handleRequest( final ObjectMapper objectMapper, final String method, ContainerNode params ) throws JsonRpcException { var command = getCommand(method); -- 2.50.1