-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4189 from ghubstan/simple-rpc-auth
Implement simple password-based gRPC authentication
- Loading branch information
Showing
12 changed files
with
455 additions
and
216 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <http://www.gnu.org/licenses/>. | ||
*/ | ||
|
||
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<String>) 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] <method>"); | ||
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)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() { | ||
} | ||
} |
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.