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 <cedb@keylimebox.org>
This commit is contained in:
ced-b 2022-11-02 12:44:12 -04:00 committed by GitHub
parent 43face8ead
commit 1ad0e94b64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 136 additions and 1 deletions

View file

@ -13,6 +13,7 @@ import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException; import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.dbus.DbusSignalControlImpl; import org.asamk.signal.dbus.DbusSignalControlImpl;
import org.asamk.signal.dbus.DbusSignalImpl; import org.asamk.signal.dbus.DbusSignalImpl;
import org.asamk.signal.http.HttpServerHandler;
import org.asamk.signal.json.JsonReceiveMessageHandler; import org.asamk.signal.json.JsonReceiveMessageHandler;
import org.asamk.signal.jsonrpc.SignalJsonRpcDispatcherHandler; import org.asamk.signal.jsonrpc.SignalJsonRpcDispatcherHandler;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
@ -69,6 +70,10 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
.nargs("?") .nargs("?")
.setConst("localhost:7583") .setConst("localhost:7583")
.help("Expose a JSON-RPC interface on a TCP socket (default 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") subparser.addArgument("--no-receive-stdout")
.help("Dont print received messages to stdout.") .help("Dont print received messages to stdout.")
.action(Arguments.storeTrue()); .action(Arguments.storeTrue());
@ -128,6 +133,16 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
final var serverChannel = IOUtils.bindSocket(address); final var serverChannel = IOUtils.bindSocket(address);
runSocketSingleAccount(m, serverChannel, receiveMode == ReceiveMode.MANUAL); 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")); final var isDbusSystem = Boolean.TRUE.equals(ns.getBoolean("dbus-system"));
if (isDbusSystem) { if (isDbusSystem) {
runDbusSingleAccount(m, true, receiveMode != ReceiveMode.ON_START); runDbusSingleAccount(m, true, receiveMode != ReceiveMode.ON_START);
@ -199,6 +214,16 @@ public class DaemonCommand implements MultiLocalCommand, LocalCommand {
final var serverChannel = IOUtils.bindSocket(address); final var serverChannel = IOUtils.bindSocket(address);
runSocketMultiAccount(c, serverChannel, receiveMode == ReceiveMode.MANUAL); 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")); final var isDbusSystem = Boolean.TRUE.equals(ns.getBoolean("dbus-system"));
if (isDbusSystem) { if (isDbusSystem) {
runDbusMultiAccount(c, receiveMode != ReceiveMode.ON_START, true); runDbusMultiAccount(c, receiveMode != ReceiveMode.ON_START, true);

View file

@ -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();
}
}

View file

@ -52,7 +52,7 @@ public class SignalJsonRpcCommandHandler {
this.commandProvider = commandProvider; this.commandProvider = commandProvider;
} }
JsonNode handleRequest( public JsonNode handleRequest(
final ObjectMapper objectMapper, final String method, ContainerNode<?> params final ObjectMapper objectMapper, final String method, ContainerNode<?> params
) throws JsonRpcException { ) throws JsonRpcException {
var command = getCommand(method); var command = getCommand(method);