diff --git a/build.gradle b/build.gradle index f12aa2d6db3..a81b44604f2 100644 --- a/build.gradle +++ b/build.gradle @@ -335,7 +335,7 @@ configure(project(':core')) { } configure(project(':cli')) { - mainClassName = 'bisq.cli.app.BisqCliMain' + mainClassName = 'bisq.cli.CliMain' dependencies { compile project(':proto') diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java new file mode 100644 index 00000000000..27f40b68b6c --- /dev/null +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -0,0 +1,189 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.cli; + +import bisq.proto.grpc.GetBalanceGrpc; +import bisq.proto.grpc.GetBalanceRequest; +import bisq.proto.grpc.GetVersionGrpc; +import bisq.proto.grpc.GetVersionRequest; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.StatusRuntimeException; + +import joptsimple.OptionException; +import joptsimple.OptionParser; +import joptsimple.OptionSet; + +import java.text.DecimalFormat; + +import java.io.IOException; +import java.io.PrintStream; + +import java.math.BigDecimal; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import lombok.extern.slf4j.Slf4j; + +import static java.lang.System.err; +import static java.lang.System.exit; +import static java.lang.System.out; + +/** + * A command-line client for the Bisq gRPC API. + */ +@Slf4j +public class CliMain { + + private static final int EXIT_SUCCESS = 0; + private static final int EXIT_FAILURE = 1; + + private enum Method { + getversion, + getbalance + } + + public static void main(String[] args) { + var parser = new OptionParser(); + + var helpOpt = parser.accepts("help", "Print this help text") + .forHelp(); + + var hostOpt = parser.accepts("host", "rpc server hostname or IP") + .withRequiredArg() + .defaultsTo("localhost"); + + var portOpt = parser.accepts("port", "rpc server port") + .withRequiredArg() + .ofType(Integer.class) + .defaultsTo(9998); + + var passwordOpt = parser.accepts("password", "rpc server password") + .withRequiredArg(); + + OptionSet options = null; + try { + options = parser.parse(args); + } catch (OptionException ex) { + err.println("Error: " + ex.getMessage()); + exit(EXIT_FAILURE); + } + + if (options.has(helpOpt)) { + printHelp(parser, out); + exit(EXIT_SUCCESS); + } + + @SuppressWarnings("unchecked") + var nonOptionArgs = (List) options.nonOptionArguments(); + if (nonOptionArgs.isEmpty()) { + printHelp(parser, err); + err.println("Error: no method specified"); + exit(EXIT_FAILURE); + } + + var methodName = nonOptionArgs.get(0); + Method method = null; + try { + method = Method.valueOf(methodName); + } catch (IllegalArgumentException ex) { + err.printf("Error: '%s' is not a supported method\n", methodName); + exit(EXIT_FAILURE); + } + + var host = options.valueOf(hostOpt); + var port = options.valueOf(portOpt); + var password = options.valueOf(passwordOpt); + if (password == null) { + err.println("Error: missing required 'password' option"); + exit(EXIT_FAILURE); + } + + var credentials = new PasswordCallCredentials(password); + + var channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + channel.shutdown().awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + ex.printStackTrace(err); + exit(EXIT_FAILURE); + } + })); + + try { + switch (method) { + case getversion: { + var stub = GetVersionGrpc.newBlockingStub(channel).withCallCredentials(credentials); + var request = GetVersionRequest.newBuilder().build(); + var version = stub.getVersion(request).getVersion(); + out.println(version); + exit(EXIT_SUCCESS); + } + case getbalance: { + var stub = GetBalanceGrpc.newBlockingStub(channel).withCallCredentials(credentials); + var request = GetBalanceRequest.newBuilder().build(); + var balance = stub.getBalance(request).getBalance(); + if (balance == -1) { + err.println("Error: server is still initializing"); + exit(EXIT_FAILURE); + } + out.println(formatBalance(balance)); + exit(EXIT_SUCCESS); + } + default: { + err.printf("Error: unhandled method '%s'\n", method); + exit(EXIT_FAILURE); + } + } + } catch (StatusRuntimeException ex) { + // This exception is thrown if the client-provided password credentials do not + // match the value set on the server. The actual error message is in a nested + // exception and we clean it up a bit to make it more presentable. + Throwable t = ex.getCause() == null ? ex : ex.getCause(); + err.println("Error: " + t.getMessage().replace("UNAUTHENTICATED: ", "")); + exit(EXIT_FAILURE); + } + } + + private static void printHelp(OptionParser parser, PrintStream stream) { + try { + stream.println("Bisq RPC Client"); + stream.println(); + stream.println("Usage: bisq-cli [options] "); + stream.println(); + parser.printHelpOn(stream); + stream.println(); + stream.println("Method Description"); + stream.println("------ -----------"); + stream.println("getversion Get server version"); + stream.println("getbalance Get server wallet balance"); + stream.println(); + } catch (IOException ex) { + ex.printStackTrace(stream); + } + } + + @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled") + private static String formatBalance(long satoshis) { + var btcFormat = new DecimalFormat("###,##0.00000000"); + var satoshiDivisor = new BigDecimal(100000000); + return btcFormat.format(BigDecimal.valueOf(satoshis).divide(satoshiDivisor)); + } +} diff --git a/cli/src/main/java/bisq/cli/PasswordCallCredentials.java b/cli/src/main/java/bisq/cli/PasswordCallCredentials.java new file mode 100644 index 00000000000..14b451d28f8 --- /dev/null +++ b/cli/src/main/java/bisq/cli/PasswordCallCredentials.java @@ -0,0 +1,45 @@ +package bisq.cli; + +import io.grpc.CallCredentials; +import io.grpc.Metadata; +import io.grpc.Metadata.Key; + +import java.util.concurrent.Executor; + +import static io.grpc.Metadata.ASCII_STRING_MARSHALLER; +import static io.grpc.Status.UNAUTHENTICATED; +import static java.lang.String.format; + +/** + * Sets the {@value PASSWORD_KEY} rpc call header to a given value. + */ +class PasswordCallCredentials extends CallCredentials { + + public static final String PASSWORD_KEY = "password"; + + private final String passwordValue; + + public PasswordCallCredentials(String passwordValue) { + if (passwordValue == null) + throw new IllegalArgumentException(format("'%s' value must not be null", PASSWORD_KEY)); + this.passwordValue = passwordValue; + } + + @Override + public void applyRequestMetadata(RequestInfo requestInfo, Executor appExecutor, MetadataApplier metadataApplier) { + appExecutor.execute(() -> { + try { + var headers = new Metadata(); + var passwordKey = Key.of(PASSWORD_KEY, ASCII_STRING_MARSHALLER); + headers.put(passwordKey, passwordValue); + metadataApplier.apply(headers); + } catch (Throwable ex) { + metadataApplier.fail(UNAUTHENTICATED.withCause(ex)); + } + }); + } + + @Override + public void thisUsesUnstableApi() { + } +} diff --git a/cli/src/main/java/bisq/cli/app/BisqCliMain.java b/cli/src/main/java/bisq/cli/app/BisqCliMain.java deleted file mode 100644 index 015051bdc32..00000000000 --- a/cli/src/main/java/bisq/cli/app/BisqCliMain.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * This file is part of Bisq. - * - * Bisq is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at - * your option) any later version. - * - * Bisq is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public - * License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with Bisq. If not, see . - */ - -package bisq.cli.app; - -import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; - -import joptsimple.OptionParser; -import joptsimple.OptionSet; -import joptsimple.OptionSpec; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -import lombok.extern.slf4j.Slf4j; - -import static bisq.cli.app.CommandParser.GETBALANCE; -import static bisq.cli.app.CommandParser.GETVERSION; -import static bisq.cli.app.CommandParser.HELP; -import static bisq.cli.app.CommandParser.STOPSERVER; -import static java.lang.String.format; -import static java.lang.System.exit; -import static java.lang.System.out; - -/** - * gRPC client. - */ -@Slf4j -public class BisqCliMain { - - private static final int EXIT_SUCCESS = 0; - private static final int EXIT_FAILURE = 1; - - private final ManagedChannel channel; - private final CliCommand cmd; - private final OptionParser parser; - - public static void main(String[] args) { - new BisqCliMain("localhost", 9998, args); - } - - private BisqCliMain(String host, int port, String[] args) { - // Channels are secure by default (via SSL/TLS); for the example disable TLS to avoid needing certificates. - this(ManagedChannelBuilder.forAddress(host, port).usePlaintext().build()); - String command = parseCommand(args); - String result = runCommand(command); - out.println(result); - try { - shutdown(); // Orderly channel shutdown - } catch (InterruptedException ignored) { - } - } - - /** - * Construct client for accessing server using the existing channel. - */ - private BisqCliMain(ManagedChannel channel) { - this.channel = channel; - this.cmd = new CliCommand(channel); - this.parser = new CommandParser().configure(); - } - - private String runCommand(String command) { - final String result; - switch (command) { - case HELP: - CommandParser.printHelp(); - exit(EXIT_SUCCESS); - case GETBALANCE: - long satoshis = cmd.getBalance(); - result = satoshis == -1 ? "Server initializing..." : cmd.prettyBalance.apply(satoshis); - break; - case GETVERSION: - result = cmd.getVersion(); - break; - case STOPSERVER: - cmd.stopServer(); - result = "Server stopped"; - break; - default: - result = format("Unknown command '%s'", command); - } - return result; - } - - private String parseCommand(String[] params) { - OptionSpec nonOptions = parser.nonOptions().ofType(String.class); - OptionSet options = parser.parse(params); - List detectedOptions = nonOptions.values(options); - if (detectedOptions.isEmpty()) { - CommandParser.printHelp(); - exit(EXIT_FAILURE); - } - return detectedOptions.get(0); - } - - private void shutdown() throws InterruptedException { - channel.shutdown().awaitTermination(1, TimeUnit.SECONDS); - exit(EXIT_SUCCESS); - } -} diff --git a/cli/src/main/java/bisq/cli/app/CliCommand.java b/cli/src/main/java/bisq/cli/app/CliCommand.java deleted file mode 100644 index e3b0bc813fe..00000000000 --- a/cli/src/main/java/bisq/cli/app/CliCommand.java +++ /dev/null @@ -1,66 +0,0 @@ -package bisq.cli.app; - -import bisq.proto.grpc.GetBalanceGrpc; -import bisq.proto.grpc.GetBalanceRequest; -import bisq.proto.grpc.GetVersionGrpc; -import bisq.proto.grpc.GetVersionRequest; -import bisq.proto.grpc.StopServerGrpc; -import bisq.proto.grpc.StopServerRequest; - -import io.grpc.ManagedChannel; -import io.grpc.StatusRuntimeException; - -import java.text.DecimalFormat; - -import java.math.BigDecimal; - -import java.util.function.Function; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -final class CliCommand { - - private final GetBalanceGrpc.GetBalanceBlockingStub getBalanceStub; - private final GetVersionGrpc.GetVersionBlockingStub getVersionStub; - private final StopServerGrpc.StopServerBlockingStub stopServerStub; - - private final DecimalFormat btcFormat = new DecimalFormat("###,##0.00000000"); - private final BigDecimal satoshiDivisor = new BigDecimal(100000000); - @SuppressWarnings("BigDecimalMethodWithoutRoundingCalled") - final Function prettyBalance = (sats) -> btcFormat.format(BigDecimal.valueOf(sats).divide(satoshiDivisor)); - - CliCommand(ManagedChannel channel) { - getBalanceStub = GetBalanceGrpc.newBlockingStub(channel); - getVersionStub = GetVersionGrpc.newBlockingStub(channel); - stopServerStub = StopServerGrpc.newBlockingStub(channel); - } - - String getVersion() { - GetVersionRequest request = GetVersionRequest.newBuilder().build(); - try { - return getVersionStub.getVersion(request).getVersion(); - } catch (StatusRuntimeException e) { - return "RPC failed: " + e.getStatus(); - } - } - - long getBalance() { - GetBalanceRequest request = GetBalanceRequest.newBuilder().build(); - try { - return getBalanceStub.getBalance(request).getBalance(); - } catch (StatusRuntimeException e) { - log.warn("RPC failed: {}", e.getStatus()); - return -1; - } - } - - void stopServer() { - StopServerRequest request = StopServerRequest.newBuilder().build(); - try { - stopServerStub.stopServer(request); - } catch (StatusRuntimeException e) { - log.warn("RPC failed: {}", e.getStatus()); - } - } -} diff --git a/cli/src/main/java/bisq/cli/app/CommandParser.java b/cli/src/main/java/bisq/cli/app/CommandParser.java deleted file mode 100644 index b1615d81165..00000000000 --- a/cli/src/main/java/bisq/cli/app/CommandParser.java +++ /dev/null @@ -1,27 +0,0 @@ -package bisq.cli.app; - -import joptsimple.OptionParser; - -import static java.lang.System.out; - -final class CommandParser { - - // Option name constants - static final String HELP = "help"; - static final String GETBALANCE = "getbalance"; - static final String GETVERSION = "getversion"; - static final String STOPSERVER = "stopserver"; - - OptionParser configure() { - OptionParser parser = new OptionParser(); - parser.allowsUnrecognizedOptions(); - parser.nonOptions(GETBALANCE).ofType(String.class).describedAs("get btc balance"); - parser.nonOptions(GETVERSION).ofType(String.class).describedAs("get bisq version"); - return parser; - } - - static void printHelp() { - out.println("Usage: bisq-cli getbalance | getversion"); - } - -} diff --git a/cli/test.sh b/cli/test.sh new file mode 100755 index 00000000000..046cbd910aa --- /dev/null +++ b/cli/test.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# +# References & examples for expect: +# +# - https://pantz.org/software/expect/expect_examples_and_tips.html +# - https://stackoverflow.com/questions/13982310/else-string-matching-in-expect +# - https://gist.github.com/Fluidbyte/6294378 +# - https://www.oreilly.com/library/view/exploring-expect/9781565920903/ch04.html +# +# Prior to running this script, run: +# +# ./bisq-daemon --apiPassword=xyz +# +# The data directory used must contain an unencrypted wallet with a 0 BTC balance + +# Ensure project root is the current working directory +cd $(dirname $0)/.. + +OUTPUT=$(expect -c ' + # exp_internal 1 + puts "TEST unsupported cmd error" + set expected "Error: '\''bogus'\'' is not a supported method" + spawn ./bisq-cli --password=xyz bogus + expect { + $expected { puts "PASS" } + default { + set results $expect_out(buffer) + puts "FAIL expected = $expected" + puts " actual = $results" + } + } +') +echo "$OUTPUT" +echo "========================================================================" + +OUTPUT=$(expect -c ' + puts "TEST unrecognized option error" + set expected "Error: bogus is not a recognized option" + spawn ./bisq-cli --bogus getversion + expect { + $expected { puts "PASS" } + default { + set results $expect_out(buffer) + puts "FAIL expected = $expected" + puts " actual = $results" + } + } +') +echo "$OUTPUT" +echo "========================================================================" + +OUTPUT=$(expect -c ' + # exp_internal 1 + puts "TEST missing required password option error" + set expected "Error: missing required '\''password'\'' option" + spawn ./bisq-cli getversion + expect { + $expected { puts "PASS" } + default { + set results $expect_out(buffer) + puts "FAIL expected = $expected" + puts " actual = $results" + } + } +') +echo "$OUTPUT" +echo "========================================================================" + +OUTPUT=$(expect -c ' + # exp_internal 1 + puts "TEST getversion (incorrect password error)" + set expected "Error: incorrect '\''password'\'' rpc header value" + spawn ./bisq-cli --password=bogus getversion + expect { + $expected { puts "PASS\n" } + default { + set results $expect_out(buffer) + puts "FAIL expected = $expected" + puts " actual = $results" + } + } +') +echo "$OUTPUT" +echo "========================================================================" + +OUTPUT=$(expect -c ' + # exp_internal 1 + puts "TEST getversion (password value in quotes)" + set expected "1.3.2" + # Note: have to define quoted argument in a variable as "''value''" + set pwd_in_quotes "''xyz''" + spawn ./bisq-cli --password=$pwd_in_quotes getversion + expect { + $expected { puts "PASS" } + default { + set results $expect_out(buffer) + puts "FAIL expected = $expected" + puts " actual = $results" + } + } +') +echo "$OUTPUT" +echo "========================================================================" + +OUTPUT=$(expect -c ' + puts "TEST getversion" + set expected "1.3.2" + spawn ./bisq-cli --password=xyz getversion + expect { + $expected { puts "PASS" } + default { + set results $expect_out(buffer) + puts "FAIL expected = $expected" + puts " actual = $results" + } + } +') +echo "$OUTPUT" +echo "========================================================================" + +OUTPUT=$(expect -c ' + puts "TEST getbalance" + # exp_internal 1 + set expected "0.00000000" + spawn ./bisq-cli --password=xyz getbalance + expect { + $expected { puts "PASS" } + default { + set results $expect_out(buffer) + puts "FAIL expected = $expected" + puts " actual = $results" + } + } +') +echo "$OUTPUT" +echo "========================================================================" + +OUTPUT=$(expect -c ' + puts "TEST running with no options or arguments prints help text" + # exp_internal 1 + set expected "Bisq RPC Client" + spawn ./bisq-cli + expect { + $expected { puts "PASS" } + default { + set results $expect_out(buffer) + puts "FAIL expected = $expected" + puts " actual = $results" + } + } +') +echo "$OUTPUT" +echo "========================================================================" + +echo "TEST --help option prints help text" +./bisq-cli --help diff --git a/common/src/main/java/bisq/common/config/Config.java b/common/src/main/java/bisq/common/config/Config.java index b959d4ec1c4..fb5f16659ec 100644 --- a/common/src/main/java/bisq/common/config/Config.java +++ b/common/src/main/java/bisq/common/config/Config.java @@ -116,6 +116,8 @@ public class Config { public static final String DAO_ACTIVATED = "daoActivated"; public static final String DUMP_DELAYED_PAYOUT_TXS = "dumpDelayedPayoutTxs"; public static final String ALLOW_FAULTY_DELAYED_TXS = "allowFaultyDelayedTxs"; + public static final String API_PASSWORD = "apiPassword"; + public static final String API_PORT = "apiPort"; // Default values for certain options public static final int UNSPECIFIED_PORT = -1; @@ -140,7 +142,7 @@ public class Config { public final boolean helpRequested; public final File configFile; - // Options supported both at the cli and in the config file + // Options supported on cmd line and in the config file public final String appName; public final File userDataDir; public final File appDataDir; @@ -199,6 +201,8 @@ public class Config { public final long genesisTotalSupply; public final boolean dumpDelayedPayoutTxs; public final boolean allowFaultyDelayedTxs; + public final String apiPassword; + public final int apiPort; // Properties derived from options but not exposed as options themselves public final File torDir; @@ -206,7 +210,7 @@ public class Config { public final File storageDir; public final File keyStorageDir; - // The parser that will be used to parse both cli and config file options + // The parser that will be used to parse both cmd line and config file options private final OptionParser parser = new OptionParser(); /** @@ -615,6 +619,17 @@ public Config(String defaultAppName, File defaultUserDataDir, String... args) { .ofType(boolean.class) .defaultsTo(false); + ArgumentAcceptingOptionSpec apiPasswordOpt = + parser.accepts(API_PASSWORD, "gRPC API password") + .withRequiredArg() + .defaultsTo(""); + + ArgumentAcceptingOptionSpec apiPortOpt = + parser.accepts(API_PORT, "gRPC API port") + .withRequiredArg() + .ofType(Integer.class) + .defaultsTo(9998); + try { CompositeOptionSet options = new CompositeOptionSet(); @@ -727,6 +742,8 @@ public Config(String defaultAppName, File defaultUserDataDir, String... args) { this.daoActivated = options.valueOf(daoActivatedOpt); this.dumpDelayedPayoutTxs = options.valueOf(dumpDelayedPayoutTxsOpt); this.allowFaultyDelayedTxs = options.valueOf(allowFaultyDelayedTxsOpt); + this.apiPassword = options.valueOf(apiPasswordOpt); + this.apiPort = options.valueOf(apiPortOpt); } catch (OptionException ex) { throw new ConfigException("problem parsing option '%s': %s", ex.options().get(0), diff --git a/core/src/main/java/bisq/core/grpc/BisqGrpcServer.java b/core/src/main/java/bisq/core/grpc/GrpcServer.java similarity index 51% rename from core/src/main/java/bisq/core/grpc/BisqGrpcServer.java rename to core/src/main/java/bisq/core/grpc/GrpcServer.java index ef6631462f9..0ae8ae9ac85 100644 --- a/core/src/main/java/bisq/core/grpc/BisqGrpcServer.java +++ b/core/src/main/java/bisq/core/grpc/GrpcServer.java @@ -22,6 +22,8 @@ import bisq.core.trade.handlers.TransactionResultHandler; import bisq.core.trade.statistics.TradeStatistics2; +import bisq.common.config.Config; + import bisq.proto.grpc.GetBalanceGrpc; import bisq.proto.grpc.GetBalanceReply; import bisq.proto.grpc.GetBalanceRequest; @@ -40,99 +42,112 @@ import bisq.proto.grpc.PlaceOfferGrpc; import bisq.proto.grpc.PlaceOfferReply; import bisq.proto.grpc.PlaceOfferRequest; -import bisq.proto.grpc.StopServerGrpc; -import bisq.proto.grpc.StopServerReply; -import bisq.proto.grpc.StopServerRequest; -import io.grpc.Server; import io.grpc.ServerBuilder; import io.grpc.stub.StreamObserver; import java.io.IOException; -import java.util.List; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; - -/** - * gRPC server. Gets a instance of BisqFacade passed to access data from the running Bisq instance. - */ @Slf4j -public class BisqGrpcServer { +public class GrpcServer { - private Server server; + private final CoreApi coreApi; + private final int port; - private static BisqGrpcServer instance; - private static CoreApi coreApi; + public GrpcServer(Config config, CoreApi coreApi) { + this.coreApi = coreApi; + this.port = config.apiPort; + try { + var server = ServerBuilder.forPort(port) + .addService(new GetVersionService()) + .addService(new GetBalanceService()) + .addService(new GetTradeStatisticsService()) + .addService(new GetOffersService()) + .addService(new GetPaymentAccountsService()) + .addService(new PlaceOfferService()) + .intercept(new PasswordAuthInterceptor(config.apiPassword)) + .build() + .start(); + + log.info("listening on port {}", port); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + server.shutdown(); + log.info("shutdown complete"); + })); - /////////////////////////////////////////////////////////////////////////////////////////// - // Services - /////////////////////////////////////////////////////////////////////////////////////////// + } catch (IOException e) { + log.error(e.toString(), e); + } + } - static class GetVersionImpl extends GetVersionGrpc.GetVersionImplBase { + class GetVersionService extends GetVersionGrpc.GetVersionImplBase { @Override public void getVersion(GetVersionRequest req, StreamObserver responseObserver) { - GetVersionReply reply = GetVersionReply.newBuilder().setVersion(coreApi.getVersion()).build(); + var reply = GetVersionReply.newBuilder().setVersion(coreApi.getVersion()).build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } } - static class GetBalanceImpl extends GetBalanceGrpc.GetBalanceImplBase { + class GetBalanceService extends GetBalanceGrpc.GetBalanceImplBase { @Override public void getBalance(GetBalanceRequest req, StreamObserver responseObserver) { - GetBalanceReply reply = GetBalanceReply.newBuilder().setBalance(coreApi.getAvailableBalance()).build(); + var reply = GetBalanceReply.newBuilder().setBalance(coreApi.getAvailableBalance()).build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } } - static class GetTradeStatisticsImpl extends GetTradeStatisticsGrpc.GetTradeStatisticsImplBase { + class GetTradeStatisticsService extends GetTradeStatisticsGrpc.GetTradeStatisticsImplBase { @Override public void getTradeStatistics(GetTradeStatisticsRequest req, StreamObserver responseObserver) { - List tradeStatistics = coreApi.getTradeStatistics().stream() + + var tradeStatistics = coreApi.getTradeStatistics().stream() .map(TradeStatistics2::toProtoTradeStatistics2) .collect(Collectors.toList()); - GetTradeStatisticsReply reply = GetTradeStatisticsReply.newBuilder().addAllTradeStatistics(tradeStatistics).build(); + + var reply = GetTradeStatisticsReply.newBuilder().addAllTradeStatistics(tradeStatistics).build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } } - static class GetOffersImpl extends GetOffersGrpc.GetOffersImplBase { + class GetOffersService extends GetOffersGrpc.GetOffersImplBase { @Override public void getOffers(GetOffersRequest req, StreamObserver responseObserver) { - List tradeStatistics = coreApi.getOffers().stream() + var tradeStatistics = coreApi.getOffers().stream() .map(Offer::toProtoMessage) .collect(Collectors.toList()); - GetOffersReply reply = GetOffersReply.newBuilder().addAllOffers(tradeStatistics).build(); + var reply = GetOffersReply.newBuilder().addAllOffers(tradeStatistics).build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } } - static class GetPaymentAccountsImpl extends GetPaymentAccountsGrpc.GetPaymentAccountsImplBase { + class GetPaymentAccountsService extends GetPaymentAccountsGrpc.GetPaymentAccountsImplBase { @Override public void getPaymentAccounts(GetPaymentAccountsRequest req, StreamObserver responseObserver) { - List tradeStatistics = coreApi.getPaymentAccounts().stream() + var tradeStatistics = coreApi.getPaymentAccounts().stream() .map(PaymentAccount::toProtoMessage) .collect(Collectors.toList()); - GetPaymentAccountsReply reply = GetPaymentAccountsReply.newBuilder().addAllPaymentAccounts(tradeStatistics).build(); + var reply = GetPaymentAccountsReply.newBuilder().addAllPaymentAccounts(tradeStatistics).build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } } - static class PlaceOfferImpl extends PlaceOfferGrpc.PlaceOfferImplBase { + class PlaceOfferService extends PlaceOfferGrpc.PlaceOfferImplBase { @Override public void placeOffer(PlaceOfferRequest req, StreamObserver responseObserver) { TransactionResultHandler resultHandler = transaction -> { @@ -153,73 +168,4 @@ public void placeOffer(PlaceOfferRequest req, StreamObserver re resultHandler); } } - - static class StopServerImpl extends StopServerGrpc.StopServerImplBase { - @Override - public void stopServer(StopServerRequest req, StreamObserver responseObserver) { - StopServerReply reply = StopServerReply.newBuilder().build(); - responseObserver.onNext(reply); - responseObserver.onCompleted(); - - instance.stop(); - } - } - - - /////////////////////////////////////////////////////////////////////////////////////////// - // Constructor - /////////////////////////////////////////////////////////////////////////////////////////// - - public BisqGrpcServer(CoreApi coreApi) { - instance = this; - - BisqGrpcServer.coreApi = coreApi; - - try { - start(); - - } catch (IOException e) { - log.error(e.toString(), e); - } - } - - - /////////////////////////////////////////////////////////////////////////////////////////// - // API - /////////////////////////////////////////////////////////////////////////////////////////// - - public void stop() { - if (server != null) { - server.shutdown(); - } - } - - /////////////////////////////////////////////////////////////////////////////////////////// - // Private - /////////////////////////////////////////////////////////////////////////////////////////// - - private void start() throws IOException { - // TODO add to options - int port = 9998; - - // Config services - server = ServerBuilder.forPort(port) - .addService(new GetVersionImpl()) - .addService(new GetBalanceImpl()) - .addService(new GetTradeStatisticsImpl()) - .addService(new GetOffersImpl()) - .addService(new GetPaymentAccountsImpl()) - .addService(new PlaceOfferImpl()) - .addService(new StopServerImpl()) - .build() - .start(); - - log.info("Server started, listening on " + port); - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - // Use stderr here since the logger may have been reset by its JVM shutdown hook. - log.error("*** shutting down gRPC server since JVM is shutting down"); - BisqGrpcServer.this.stop(); - log.error("*** server shut down"); - })); - } } diff --git a/core/src/main/java/bisq/core/grpc/PasswordAuthInterceptor.java b/core/src/main/java/bisq/core/grpc/PasswordAuthInterceptor.java new file mode 100644 index 00000000000..2ab29bcdc95 --- /dev/null +++ b/core/src/main/java/bisq/core/grpc/PasswordAuthInterceptor.java @@ -0,0 +1,45 @@ +package bisq.core.grpc; + +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.StatusRuntimeException; + +import static io.grpc.Metadata.ASCII_STRING_MARSHALLER; +import static io.grpc.Metadata.Key; +import static io.grpc.Status.UNAUTHENTICATED; +import static java.lang.String.format; + +/** + * Authorizes rpc server calls by comparing the value of the caller's + * {@value PASSWORD_KEY} header to an expected value set at server startup time. + * + * @see bisq.common.config.Config#apiPassword + */ +class PasswordAuthInterceptor implements ServerInterceptor { + + public static final String PASSWORD_KEY = "password"; + + private final String expectedPasswordValue; + + public PasswordAuthInterceptor(String expectedPasswordValue) { + this.expectedPasswordValue = expectedPasswordValue; + } + + @Override + public ServerCall.Listener interceptCall(ServerCall serverCall, Metadata headers, + ServerCallHandler serverCallHandler) { + var actualPasswordValue = headers.get(Key.of(PASSWORD_KEY, ASCII_STRING_MARSHALLER)); + + if (actualPasswordValue == null) + throw new StatusRuntimeException(UNAUTHENTICATED.withDescription( + format("missing '%s' rpc header value", PASSWORD_KEY))); + + if (!actualPasswordValue.equals(expectedPasswordValue)) + throw new StatusRuntimeException(UNAUTHENTICATED.withDescription( + format("incorrect '%s' rpc header value", PASSWORD_KEY))); + + return serverCallHandler.startCall(serverCall, headers); + } +} diff --git a/daemon/src/main/java/bisq/daemon/app/BisqDaemonMain.java b/daemon/src/main/java/bisq/daemon/app/BisqDaemonMain.java index bcdd930d006..698f8ebe92d 100644 --- a/daemon/src/main/java/bisq/daemon/app/BisqDaemonMain.java +++ b/daemon/src/main/java/bisq/daemon/app/BisqDaemonMain.java @@ -20,7 +20,7 @@ import bisq.core.app.BisqHeadlessAppMain; import bisq.core.app.BisqSetup; import bisq.core.app.CoreModule; -import bisq.core.grpc.BisqGrpcServer; +import bisq.core.grpc.GrpcServer; import bisq.core.grpc.CoreApi; import bisq.common.UserThread; @@ -99,6 +99,6 @@ protected void onApplicationStarted() { super.onApplicationStarted(); CoreApi coreApi = injector.getInstance(CoreApi.class); - new BisqGrpcServer(coreApi); + new GrpcServer(config, coreApi); } } diff --git a/daemon/src/main/java/resources/logback.xml b/daemon/src/main/resources/logback.xml similarity index 100% rename from daemon/src/main/java/resources/logback.xml rename to daemon/src/main/resources/logback.xml diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index 6a013daa8b1..b1e7d084a65 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -127,18 +127,3 @@ message PlaceOfferRequest { message PlaceOfferReply { bool result = 1; } - -/////////////////////////////////////////////////////////////////////////////////////////// -// StopServer -/////////////////////////////////////////////////////////////////////////////////////////// - -service StopServer { - rpc StopServer (StopServerRequest) returns (StopServerReply) { - } -} - -message StopServerRequest { -} - -message StopServerReply { -}