From a1d69af597eb64a3a23c687ad48013ab6667876a Mon Sep 17 00:00:00 2001 From: Pierre-Marie Padiou Date: Thu, 11 Jan 2018 19:23:17 +0100 Subject: [PATCH] Fixed eclair-cli (#354) * reworked eclair-cli * API is disabled by default, disabled CORS and require basic auth password * better error handling * Fixed latest version in README * Increased connection timeout to 15s in electrum client test * Rgb in NodeAnnouncement is now a Color object Makes the color field more practical to handle and enable finer serialization with a more readable code. allnodes command in api now exports a list with node announcements. * Added api call to list all the channels updates This call can also filter the channels for a given nodeId This fixes #344. --- README.md | 54 ++-- eclair-core/eclair-cli | 144 +++++---- eclair-core/src/main/resources/reference.conf | 2 + .../scala/fr/acinq/eclair/NodeParams.scala | 5 +- .../main/scala/fr/acinq/eclair/Setup.scala | 46 ++- .../fr/acinq/eclair/api/JsonSerializers.scala | 32 +- .../scala/fr/acinq/eclair/api/Service.scala | 290 ++++++++++-------- .../acinq/eclair/channel/ChannelTypes.scala | 4 +- .../acinq/eclair/router/Announcements.scala | 8 +- .../scala/fr/acinq/eclair/router/Router.scala | 6 +- .../eclair/wire/LightningMessageCodecs.scala | 2 +- .../eclair/wire/LightningMessageTypes.scala | 8 +- .../scala/fr/acinq/eclair/TestConstants.scala | 5 +- .../electrum/ElectrumClientSpec.scala | 2 +- .../acinq/eclair/db/SqliteNetworkDbSpec.scala | 7 +- .../acinq/eclair/router/BaseRouterSpec.scala | 12 +- .../wire/LightningMessageCodecsSpec.scala | 4 +- .../src/main/resources/gui/splash/splash.fxml | 2 +- .../scala/fr/acinq/eclair/gui/FxApp.scala | 5 +- .../fr/acinq/eclair/gui/FxPreloader.scala | 2 +- .../gui/controllers/MainController.scala | 5 +- 21 files changed, 380 insertions(+), 265 deletions(-) diff --git a/README.md b/README.md index 6ef2fe5b7..8d95686e3 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Please see the latest [release note](https://github.com/ACINQ/eclair/releases) f ## Installation -:warning: **Those are valid for the most up-to-date, unreleased, version of eclair. Here are the [instructions for Eclair 0.2-alpha5](https://github.com/ACINQ/eclair/blob/v0.2-alpha5/README.md#installation)**. +:warning: **Those are valid for the most up-to-date, unreleased, version of eclair. Here are the [instructions for Eclair 0.2-alpha8](https://github.com/ACINQ/eclair/blob/v0.2-alpha8/README.md#installation)**. ### Configuring Bitcoin Core @@ -93,7 +93,10 @@ Here are some of the most common options: name | description | default value -----------------------------|---------------------------|-------------- eclair.server.port | Lightning TCP port | 9735 + eclair.api.enabled | Enable/disable the API | false. By default the API is disabled. If you want to enable it, you must set a user/password. eclair.api.port | API HTTP port | 8080 + eclair.api.user | API user (BASIC) | "" (must be set if the API is enabled) + eclair.api.password | API password (BASIC) | "" (must be set if the API is enabled) eclair.bitcoind.rpcuser | Bitcoin Core RPC user | foo eclair.bitcoind.rpcpassword | Bitcoin Core RPC password | bar eclair.bitcoind.zmq | Bitcoin Core ZMQ address | tcp://127.0.0.1:29000 @@ -121,27 +124,30 @@ java -Declair.datadir=/tmp/node1 -jar eclair-node-gui--.jar ## JSON-RPC API - method | params | description - -------------|-----------------------------------------------|----------------------------------------------------------- - getinfo | | return basic node information (id, chain hash, current block height) - connect | uri | open a secure connection to a lightning node - open | nodeId, fundingSatoshis, pushMsat | open a channel with another lightning node - peers | | list existing local peers - channels | | list existing local channels - channels | nodeId | list existing local channels opened with a particular nodeId - channel | channelId | retrieve detailed information about a given channel - allnodes | | list all known nodes - allchannels | | list all known channels - receive | description | generate a payment request without a required amount (can be useful for donations) - receive | amountMsat, description | generate a payment request for a given amount - send | amountMsat, paymentHash, nodeId | send a payment to a lightning node - send | paymentRequest | send a payment to a lightning node using a BOLT11 payment request - send | paymentRequest, amountMsat | send a payment to a lightning node using a BOLT11 payment request and a custom amount - checkpayment| paymentHash | returns true if the payment has been received, false otherwise - checkpayment| paymentRequest | returns true if the payment has been received, false otherwise - close | channelId | close a channel - close | channelId, scriptPubKey (optional) | close a channel and send the funds to the given scriptPubKey - help | | display available methods + method | params | description + ------------- |------------------------------------------------------------|----------------------------------------------------------- + getinfo | | return basic node information (id, chain hash, current block height) + connect | nodeId, host, port | open a secure connection to a lightning node + connect | uri | open a secure connection to a lightning node + open | nodeId, fundingSatoshis, pushMsat = 0, channelFlags = 0x01 | open a channel with another lightning node, by default push = 0 and channel is announced + peers | | list existing local peers + channels | | list existing local channels + channels | nodeId | list existing local channels opened with a particular nodeId + channel | channelId | retrieve detailed information about a given channel + allnodes | | list all known nodes + allchannels | | list all known channels + allupdates | | list all channels updates + allupdates | nodeId | list all channels updates for this nodeId + receive | description | generate a payment request without a required amount (can be useful for donations) + receive | amountMsat, description | generate a payment request for a given amount + send | amountMsat, paymentHash, nodeId | send a payment to a lightning node + send | paymentRequest | send a payment to a lightning node using a BOLT11 payment request + send | paymentRequest, amountMsat | send a payment to a lightning node using a BOLT11 payment request and a custom amount + checkpayment | paymentHash | returns true if the payment has been received, false otherwise + checkpayment | paymentRequest | returns true if the payment has been received, false otherwise + close | channelId | close a channel + close | channelId, scriptPubKey | close a channel and send the funds to the given scriptPubKey + help | | display available methods ## Docker @@ -161,8 +167,8 @@ docker run -ti --rm -v "/path_on_host:/data" -e "JAVA_OPTS=-Declair.printToConso ## Resources -- [1] [The Bitcoin Lightning Network: Scalable Off-Chain Instant Payments](https://lightning.network/lightning-network-paper.pdf) by Joseph Poon and Thaddeus Dryja -- [2] [Reaching The Ground With Lightning](https://github.com/ElementsProject/lightning/raw/master/doc/deployable-lightning.pdf) by Rusty Russell +- [1] [The Bitcoin Lightning Network: Scalable Off-Chain Instant Payments](https://lightning.network/lightning-network-paper.pdf) by Joseph Poon and Thaddeus Dryja +- [2] [Reaching The Ground With Lightning](https://github.com/ElementsProject/lightning/raw/master/doc/deployable-lightning.pdf) by Rusty Russell - [3] [Lightning Network Explorer](https://explorer.acinq.co) - Explore testnet LN nodes you can connect to [Amiko-Pay]: https://github.com/cornwarecjp/amiko-pay diff --git a/eclair-core/eclair-cli b/eclair-core/eclair-cli index 61f27dfc2..32f8d729a 100644 --- a/eclair-core/eclair-cli +++ b/eclair-core/eclair-cli @@ -1,59 +1,95 @@ #!/bin/bash -[ -z "$1" ] && ( -echo "usage: " -echo " eclair-cli help" -) && exit 1 +# Check if jq is installed. If not, display instructions and abort program +command -v jq >/dev/null 2>&1 || { echo -e "This tool requires jq.\nFor installation instructions, visit https://stedolan.github.io/jq/download/.\n\nAborting..."; exit 1; } -URL="http://localhost:8080" -CURL_OPTS="-sS -X POST -H \"Content-Type: application/json\"" +FULL_OUTPUT='false' +URL='http://localhost:8080' +PASSWORD='' + +# -------------------- METHODS + +displayhelp() { + echo -e "Usage: eclair-cli [OPTION]... [COMMAND] +Client for an eclair node. + +With COMMAND is one of the command listed by \e[01;33meclair-cli help\e[0m. + + -p api's password + -a
Override the api URL with
+ -v Outputs full json returned by the API + +Examples: + eclair-cli -a localhost:1234 peers list the peers + eclair-cli close 006fb... closes the channel with id 006fb... + +Note: Uses the json-rpc api exposed by the node on localhost:8080. Make sure the api is enabled. +Full documentation at: " +} + +# Executes a JSON RPC call to a node listening on ${URL} +call() { + jqexp='if .error == null then .result else .error.message end' + # override default jq parsing expression + if [ $# -ge 3 ] && [ ${FULL_OUTPUT} == "false" ]; then jqexp=${3}; fi + # set password + if [ -z ${PASSWORD} ]; then auth="eclair-cli"; + else auth="eclair-cli:"${PASSWORD}; fi + eval curl "--user ${auth} --silent --show-error -X POST -H \"Content-Type: application/json\" -d '{ \"method\": \"'${1}'\", \"params\": '${2}' }' ${URL}" | jq -r "$jqexp" +} + +# get script options +while getopts 'vu:p:a:' flag; do + case "${flag}" in + p) PASSWORD="${OPTARG}" ;; + a) URL="${OPTARG}" ;; + v) FULL_OUTPUT="true" ;; + *) echo -e "\nAborting..."; exit 1; ;; + esac +done + +shift $(($OPTIND - 1)) + +# assigning JSON RPC method and params values from arguments +METHOD=${1} +shift 1 + +# Create a JSON Array containing the remaining program args as QUOTED STRINGS, separated with a `,` character +PARAMS="" +i=1 +for arg in "${@}"; do + if [ $i -eq 1 ]; then PARAMS=$(printf '"%s"' "$arg"); + else PARAMS=$(printf '%s,"%s"' "$PARAMS" "$arg"); + fi + let "i++" +done; +PARAMS="[${PARAMS}]" + +# Whatever the arguments provided to eclair-cli, a call to the API will be sent. Let it fail! +case ${METHOD}_${#} in + ""_*) displayhelp ;; + "help"*) displayhelp + echo -e "\nAvailable commands:\n" + call "help" [] ;; + + "connect_3") call ${METHOD} "'$(printf '["%s","%s",%s]' "${1}" "${2}" "${3}")'" ;; # ${3} is numeric + + "open_4") call ${METHOD} "'$(printf '["%s",%s,%s,%s]' "${1}" "${2}" "${3}" "${4}")'" ;; # ${2} ${3} ${4} are numeric (funding, push, flags) + "open_3") call ${METHOD} "'$(printf '["%s",%s,%s]' "${1}" "${2}" "${3}")'" ;; # ${2} ${3} are numeric (funding, push) + "open_2") call ${METHOD} "'$(printf '["%s",%s]' "${1}" "${2}")'" ;; # ${2} is numeric (funding) + + "receive_2") call ${METHOD} "'$(printf '[%s,"%s"]' "${1}" "${2}")'" ;; # ${1} is numeric (amount to receive) + + "channel_"*) call ${METHOD} "'${PARAMS}'" "if .error != null then .error.message else .result | { nodeId, shortChannelId: .data.shortChannelId, channelId, state, balanceMsat: .data.commitments.localCommit.spec.toLocalMsat, capacitySat: .data.commitments.commitInput.txOut.amount.amount } end" ;; + + "send_3") call ${METHOD} "'$(printf '[%s,"%s","%s"]' "${1}" "${2}" "${3}")'" ;; # ${1} is numeric (amount of the payment) + "send_2") call ${METHOD} "'$(printf '["%s",%s]' "${1}" "${2}")'" ;; # ${2} is numeric (amount overriding the payment request) + + *) # Default case. + # Sends the method and, for parameters, use the JSON table containing the remaining args. + # + # NOTE: Arguments will be sent as QUOTED STRING so if this particular API call requires an INT param, + # this call will fail. In that case, a specific rule for that method MUST be set and the ${PARAMS} JSON array can not be used. + call ${METHOD} "'${PARAMS}'" ;; -case $1 in -"help") - eval curl "$CURL_OPTS -d '{ \"method\": \"help\", \"params\" : [] }' $URL" | jq -r ".result[]" - ;; -"getinfo") - eval curl "$CURL_OPTS -d '{ \"method\": \"getinfo\", \"params\" : [] }' $URL" | jq ".result" - ;; -"channels") - eval curl "$CURL_OPTS -d '{ \"method\": \"channels\", \"params\" : [] }' $URL" | jq ".result[]" - ;; -"channels") - if [ $# -ge 2 ] - then - eval curl "$CURL_OPTS -d '{ \"method\": \"channels\", \"params\" : [\"${2?"missing node id"}\"] }' $URL" | jq ".result[]" - else - eval curl "$CURL_OPTS -d '{ \"method\": \"channels\", \"params\" : [] }' $URL" | jq ".result[]" - fi - ;; -"channel") - eval curl "$CURL_OPTS -d '{ \"method\": \"channel\", \"params\" : [\"${2?"missing channel id"}\"] }' $URL" | jq ".result | { nodeid, channelId, state, balanceMsat: .data.commitments.localCommit.spec.toLocalMsat, capacitySat: .data.commitments.commitInput.txOut.amount.amount }" - ;; -"connect") - eval curl "$CURL_OPTS -d '{ \"method\": \"open\", \"params\" : [\"${2?"missing uri"}\"] }' $URL" | jq -r "if .error == null then .result else .error.message end" - ;; -"open") - eval curl "$CURL_OPTS -d '{ \"method\": \"open\", \"params\" : [\"${2?"missing node id"}\", ${3?"missing amount (sat)"}, ${4?"missing push amount (msat)"}] }' $URL" | jq -r "if .error == null then .result else .error.message end" - ;; -"close") - eval curl "$CURL_OPTS -d '{ \"method\": \"close\", \"params\" : [\"${2?"missing channel id"}\"] }' $URL" - ;; -"receive") - eval curl "$CURL_OPTS -d '{ \"method\": \"receive\", \"params\" : [${2?"missing amount"}, \"${3?"missing description"}\"] }' $URL" | jq -r "if .error == null then .result else .error.message end" - ;; -"send") - eval curl "$CURL_OPTS -d '{ \"method\": \"send\", \"params\" : [\"${2?"missing request"}\"] }' $URL" | jq -r "if .error == null then .result else .error.message end" - ;; -"allnodes") - eval curl "$CURL_OPTS -d '{ \"method\": \"allnodes\", \"params\" : [] }' $URL" | jq ".result" - ;; -"allchannels") - eval curl "$CURL_OPTS -d '{ \"method\": \"allchannels\", \"params\" : [] }' $URL" | jq ".result" - ;; -"peers") - eval curl "$CURL_OPTS -d '{ \"method\": \"peers\", \"params\" : [] }' $URL" | jq ".result" - ;; -"checkpayment") - eval curl "$CURL_OPTS -d '{ \"method\": \"checkpayment\", \"params\" : [\"${2?"missing payment request or payment hash"}\"] }' $URL" | jq ".result" - ;; esac diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 7db2dcd14..7fda2baec 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -9,8 +9,10 @@ eclair { } api { + enabled = false // disabled by default for security reasons binding-ip = "127.0.0.1" port = 8080 + password = "" // password for basic auth, must be non empty if json-rpc api is enabled } watcher-type = "bitcoind" // other *experimental* values include "bitcoinj" or "electrum" diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index eefbcaef2..1b1a77517 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -14,6 +14,7 @@ import fr.acinq.eclair.NodeParams.WatcherType import fr.acinq.eclair.channel.Channel import fr.acinq.eclair.db._ import fr.acinq.eclair.db.sqlite._ +import fr.acinq.eclair.wire.Color import scala.collection.JavaConversions._ import scala.concurrent.duration.FiniteDuration @@ -24,7 +25,7 @@ import scala.concurrent.duration.FiniteDuration case class NodeParams(extendedPrivateKey: ExtendedPrivateKey, privateKey: PrivateKey, alias: String, - color: (Byte, Byte, Byte), + color: Color, publicAddresses: List[InetSocketAddress], globalFeatures: BinaryData, localFeatures: BinaryData, @@ -131,7 +132,7 @@ object NodeParams { extendedPrivateKey = extendedPrivateKey, privateKey = extendedPrivateKey.privateKey, alias = config.getString("node-alias").take(32), - color = (color.data(0), color.data(1), color.data(2)), + color = Color(color.data(0), color.data(1), color.data(2)), publicAddresses = config.getStringList("server.public-ips").toList.map(ip => new InetSocketAddress(ip, config.getInt("server.port"))), globalFeatures = BinaryData(config.getString("global-features")), localFeatures = BinaryData(config.getString("local-features")), diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index a00d96afd..badf942e8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -37,9 +37,9 @@ class Setup(datadir: File, overrideDefaults: Config = ConfigFactory.empty(), act logger.info(s"hello!") logger.info(s"version=${getClass.getPackage.getImplementationVersion} commit=${getClass.getPackage.getSpecificationVersion}") - val config = NodeParams.loadConfiguration(datadir, overrideDefaults) - val nodeParams = NodeParams.makeNodeParams(datadir, config) - val chain = config.getString("chain") + val config: Config = NodeParams.loadConfiguration(datadir, overrideDefaults) + val nodeParams: NodeParams = NodeParams.makeNodeParams(datadir, config) + val chain: String = config.getString("chain") // early checks DBCompatChecker.checkDBCompatibility(nodeParams) @@ -172,31 +172,43 @@ class Setup(datadir: File, overrideDefaults: Config = ConfigFactory.empty(), act server = server, wallet = wallet) - val api = new Service { - - override def getInfoResponse: Future[GetInfoResponse] = Future.successful(GetInfoResponse(nodeId = nodeParams.privateKey.publicKey, alias = nodeParams.alias, port = config.getInt("server.port"), chainHash = nodeParams.chainHash, blockHeight = Globals.blockCount.intValue())) - - override def appKit = kit - } - val httpBound = Http().bindAndHandle(api.route, config.getString("api.binding-ip"), config.getInt("api.port")).recover { - case _: BindFailedException => throw TCPBindException(config.getInt("api.port")) - } - val zmqTimeout = after(5 seconds, using = system.scheduler)(Future.failed(BitcoinZMQConnectionTimeoutException)) val tcpTimeout = after(5 seconds, using = system.scheduler)(Future.failed(TCPBindException(config.getInt("server.port")))) - val httpTimeout = after(5 seconds, using = system.scheduler)(Future.failed(TCPBindException(config.getInt("api.port")))) for { _ <- Future.firstCompletedOf(zmqConnected.future :: zmqTimeout :: Nil) _ <- Future.firstCompletedOf(tcpBound.future :: tcpTimeout :: Nil) - _ <- Future.firstCompletedOf(httpBound :: httpTimeout :: Nil) + _ <- if (config.getBoolean("api.enabled")) { + logger.info(s"json-rpc api enabled on port=${config.getInt("api.port")}") + val api = new Service { + override val password = { + val p = config.getString("api.password") + if (p.isEmpty) throw EmptyAPIPasswordException else p + } + + override def getInfoResponse: Future[GetInfoResponse] = Future.successful( + GetInfoResponse(nodeId = nodeParams.privateKey.publicKey, + alias = nodeParams.alias, + port = config.getInt("server.port"), + chainHash = nodeParams.chainHash, + blockHeight = Globals.blockCount.intValue())) + + override def appKit: Kit = kit + } + val httpBound = Http().bindAndHandle(api.route, config.getString("api.binding-ip"), config.getInt("api.port")).recover { + case _: BindFailedException => throw TCPBindException(config.getInt("api.port")) + } + val httpTimeout = after(5 seconds, using = system.scheduler)(Future.failed(TCPBindException(config.getInt("api.port")))) + Future.firstCompletedOf(httpBound :: httpTimeout :: Nil) + } else { + Future.successful(logger.info("json-rpc api is disabled")) + } } yield kit } } - // @formatter:off sealed trait Bitcoin case class Bitcoind(extendedBitcoinClient: ExtendedBitcoinClient) extends Bitcoin @@ -219,3 +231,5 @@ case class Kit(nodeParams: NodeParams, case object BitcoinZMQConnectionTimeoutException extends RuntimeException("could not connect to bitcoind using zeromq") case object BitcoinRPCConnectionException extends RuntimeException("could not connect to bitcoind using json-rpc") + +case object EmptyAPIPasswordException extends RuntimeException("must set a user/password for the json-rpc api") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala index 090bf3451..518c4f560 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/api/JsonSerializers.scala @@ -3,17 +3,18 @@ package fr.acinq.eclair.api import java.net.InetSocketAddress import fr.acinq.bitcoin.Crypto.{Point, PrivateKey, PublicKey, Scalar} -import fr.acinq.bitcoin.{BinaryData, OutPoint, Transaction} +import fr.acinq.bitcoin.{BinaryData, OutPoint} import fr.acinq.eclair.channel.State import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.Transactions.TransactionWithInputInfo -import org.json4s.{CustomKeySerializer, CustomSerializer} +import fr.acinq.eclair.wire.Color import org.json4s.JsonAST.{JNull, JString} +import org.json4s.{CustomKeySerializer, CustomSerializer} /** * Created by PM on 28/01/2016. */ -class BinaryDataSerializer extends CustomSerializer[BinaryData](format => ( { +class BinaryDataSerializer extends CustomSerializer[BinaryData](format => ({ case JString(hex) if (false) => // NOT IMPLEMENTED ??? }, { @@ -21,7 +22,7 @@ class BinaryDataSerializer extends CustomSerializer[BinaryData](format => ( { } )) -class StateSerializer extends CustomSerializer[State](format => ( { +class StateSerializer extends CustomSerializer[State](format => ({ case JString(x) if (false) => // NOT IMPLEMENTED ??? }, { @@ -29,7 +30,7 @@ class StateSerializer extends CustomSerializer[State](format => ( { } )) -class ShaChainSerializer extends CustomSerializer[ShaChain](format => ( { +class ShaChainSerializer extends CustomSerializer[ShaChain](format => ({ case JString(x) if (false) => // NOT IMPLEMENTED ??? }, { @@ -37,7 +38,7 @@ class ShaChainSerializer extends CustomSerializer[ShaChain](format => ( { } )) -class PublicKeySerializer extends CustomSerializer[PublicKey](format => ( { +class PublicKeySerializer extends CustomSerializer[PublicKey](format => ({ case JString(x) if (false) => // NOT IMPLEMENTED ??? }, { @@ -45,7 +46,7 @@ class PublicKeySerializer extends CustomSerializer[PublicKey](format => ( { } )) -class PrivateKeySerializer extends CustomSerializer[PrivateKey](format => ( { +class PrivateKeySerializer extends CustomSerializer[PrivateKey](format => ({ case JString(x) if (false) => // NOT IMPLEMENTED ??? }, { @@ -53,7 +54,7 @@ class PrivateKeySerializer extends CustomSerializer[PrivateKey](format => ( { } )) -class PointSerializer extends CustomSerializer[Point](format => ( { +class PointSerializer extends CustomSerializer[Point](format => ({ case JString(x) if (false) => // NOT IMPLEMENTED ??? }, { @@ -61,7 +62,7 @@ class PointSerializer extends CustomSerializer[Point](format => ( { } )) -class ScalarSerializer extends CustomSerializer[Scalar](format => ( { +class ScalarSerializer extends CustomSerializer[Scalar](format => ({ case JString(x) if (false) => // NOT IMPLEMENTED ??? }, { @@ -69,7 +70,7 @@ class ScalarSerializer extends CustomSerializer[Scalar](format => ( { } )) -class TransactionWithInputInfoSerializer extends CustomSerializer[TransactionWithInputInfo](format => ( { +class TransactionWithInputInfoSerializer extends CustomSerializer[TransactionWithInputInfo](format => ({ case JString(x) if (false) => // NOT IMPLEMENTED ??? }, { @@ -77,7 +78,7 @@ class TransactionWithInputInfoSerializer extends CustomSerializer[TransactionWit } )) -class InetSocketAddressSerializer extends CustomSerializer[InetSocketAddress](format => ( { +class InetSocketAddressSerializer extends CustomSerializer[InetSocketAddress](format => ({ case JString(x) if (false) => // NOT IMPLEMENTED ??? }, { @@ -85,7 +86,7 @@ class InetSocketAddressSerializer extends CustomSerializer[InetSocketAddress](fo } )) -class OutPointKeySerializer extends CustomKeySerializer[OutPoint](format => ( { +class OutPointKeySerializer extends CustomKeySerializer[OutPoint](format => ({ case x: String => val Array(k, v) = x.split(":") OutPoint(BinaryData(k), v.toLong) @@ -93,3 +94,10 @@ class OutPointKeySerializer extends CustomKeySerializer[OutPoint](format => ( { case x: OutPoint => s"${x.hash}:${x.index}" } )) + +class ColorSerializer extends CustomSerializer[Color](format => ({ + case JString(x) if (false) => // NOT IMPLEMENTED + ??? +}, { + case c: Color => JString(c.toString) +})) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala b/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala index bcf672ceb..783afbace 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/api/Service.scala @@ -1,15 +1,13 @@ package fr.acinq.eclair.api - import akka.actor.ActorRef import akka.http.scaladsl.model.HttpMethods._ import akka.http.scaladsl.model.StatusCodes -import akka.http.scaladsl.model.StatusCodes.{register => _} import akka.http.scaladsl.model.headers.CacheDirectives.{`max-age`, `no-store`, public} -import akka.http.scaladsl.model.headers.HttpOriginRange.* import akka.http.scaladsl.model.headers._ import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.http.scaladsl.server.directives.Credentials import akka.http.scaladsl.server.directives.RouteDirectives.reject -import akka.http.scaladsl.server.{ExceptionHandler, Rejection, RejectionHandler, Route} import akka.pattern.ask import akka.util.Timeout import de.heikoseeberger.akkahttpjson4s.Json4sSupport @@ -20,9 +18,9 @@ import fr.acinq.eclair.Kit import fr.acinq.eclair.channel._ import fr.acinq.eclair.io.Peer.{GetPeerInfo, PeerInfo} import fr.acinq.eclair.io.{NodeURI, Peer} -import fr.acinq.eclair.payment.{PaymentRequest, PaymentResult, ReceivePayment, SendPayment} -import fr.acinq.eclair.payment._ -import fr.acinq.eclair.wire.{ChannelAnnouncement, NodeAnnouncement} +import fr.acinq.eclair.payment.{PaymentRequest, PaymentResult, ReceivePayment, SendPayment, _} +import fr.acinq.eclair.router.ChannelDesc +import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAnnouncement} import grizzled.slf4j.Logging import org.json4s.JsonAST.{JBool, JInt, JString} import org.json4s.{JValue, jackson} @@ -37,14 +35,15 @@ case class Error(code: Int, message: String) case class JsonRPCRes(result: AnyRef, error: Option[Error], id: String) case class Status(node_id: String) case class GetInfoResponse(nodeId: PublicKey, alias: String, port: Int, chainHash: BinaryData, blockHeight: Int) +case class LocalChannelInfo(nodeId: BinaryData, channelId: BinaryData, state: String) case class ChannelInfo(shortChannelId: String, nodeId1: PublicKey, nodeId2: PublicKey) trait RPCRejection extends Rejection { def requestId: String } final case class UnknownMethodRejection(requestId: String) extends RPCRejection final case class UnknownParamsRejection(requestId: String, message: String) extends RPCRejection -final case class NotFoundRejection(requestId: String) extends RPCRejection -final case class ValidationRejection(requestId: String, message: String) extends RPCRejection +final case class RpcValidationRejection(requestId: String, message: String) extends RPCRejection +final case class ExceptionRejection(requestId: String, message: String) extends RPCRejection // @formatter:on trait Service extends Logging { @@ -52,31 +51,38 @@ trait Service extends Logging { implicit def ec: ExecutionContext = ExecutionContext.Implicits.global implicit val serialization = jackson.Serialization - implicit val formats = org.json4s.DefaultFormats + new BinaryDataSerializer + new StateSerializer + new ShaChainSerializer + new PublicKeySerializer + new PrivateKeySerializer + new ScalarSerializer + new PointSerializer + new TransactionWithInputInfoSerializer + new InetSocketAddressSerializer + new OutPointKeySerializer + implicit val formats = org.json4s.DefaultFormats + new BinaryDataSerializer + new StateSerializer + new ShaChainSerializer + new PublicKeySerializer + new PrivateKeySerializer + new ScalarSerializer + new PointSerializer + new TransactionWithInputInfoSerializer + new InetSocketAddressSerializer + new OutPointKeySerializer + new ColorSerializer implicit val timeout = Timeout(30 seconds) implicit val shouldWritePretty: ShouldWritePretty = ShouldWritePretty.True import Json4sSupport.{marshaller, unmarshaller} + def password: String + def appKit: Kit - val customHeaders = `Access-Control-Allow-Origin`(*) :: - `Access-Control-Allow-Headers`("Content-Type, Authorization") :: - `Access-Control-Allow-Methods`(PUT, GET, POST, DELETE, OPTIONS) :: - `Cache-Control`(public, `no-store`, `max-age`(0)) :: - `Access-Control-Allow-Headers`("x-requested-with") :: Nil + def userPassAuthenticator(credentials: Credentials): Option[String] = credentials match { + case p@Credentials.Provided(id) if p.verify(password) => Some(id) + case _ => + // TODO deter brute force with a forced delay + None + } + + val customHeaders = `Access-Control-Allow-Headers`("Content-Type, Authorization") :: + `Access-Control-Allow-Methods`(POST) :: + `Cache-Control`(public, `no-store`, `max-age`(0)) :: Nil val myExceptionHandler = ExceptionHandler { case t: Throwable => - extractRequest { request => + extractRequest { _ => logger.info(s"API call failed with cause=${t.getMessage}") - complete(StatusCodes.InternalServerError, JsonRPCRes(null, Some(Error(StatusCodes.InternalServerError.intValue, t.getMessage)), request.asInstanceOf[JsonRPCBody].id)) + complete(StatusCodes.InternalServerError, JsonRPCRes(null, Some(Error(StatusCodes.InternalServerError.intValue, t.getMessage)), "-1")) } } def completeRpcFuture(requestId: String, future: Future[AnyRef]): Route = onComplete(future) { case Success(s) => completeRpc(requestId, s) - case Failure(_) => reject + case Failure(t) => reject(ExceptionRejection(requestId, t.getLocalizedMessage)) } def completeRpc(requestId: String, result: AnyRef): Route = complete(JsonRPCRes(result, None, requestId)) @@ -85,11 +91,15 @@ trait Service extends Logging { complete(StatusCodes.NotFound, JsonRPCRes(null, Some(Error(StatusCodes.NotFound.intValue, "not found")), "-1")) } .handle { - case v: ValidationRejection ⇒ complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, v.message)), v.requestId)) - case nf: NotFoundRejection ⇒ complete(StatusCodes.NotFound, JsonRPCRes(null, Some(Error(StatusCodes.NotFound.intValue, "not found")), nf.requestId)) + case _: AuthenticationFailedRejection ⇒ complete(StatusCodes.Unauthorized, JsonRPCRes(null, Some(Error(StatusCodes.Unauthorized.intValue, "Access restricted")), "-1")) + case v: RpcValidationRejection ⇒ complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, v.message)), v.requestId)) case ukm: UnknownMethodRejection ⇒ complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, "method not found")), ukm.requestId)) case p: UnknownParamsRejection ⇒ complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, s"invalid parameters for this method, should be: ${p.message}")), p.requestId)) + case m: MalformedRequestContentRejection ⇒ complete(StatusCodes.BadRequest, + JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, s"malformed parameters for this method: ${m.message}")), "-1")) + case e: ExceptionRejection ⇒ complete(StatusCodes.BadRequest, + JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, s"command failed: ${e.message}")), e.requestId)) case r ⇒ logger.error(s"API call failed with cause=$r") complete(StatusCodes.BadRequest, JsonRPCRes(null, Some(Error(StatusCodes.BadRequest.intValue, r.toString)), "-1")) } @@ -99,117 +109,142 @@ trait Service extends Logging { respondWithDefaultHeaders(customHeaders) { handleExceptions(myExceptionHandler) { handleRejections(myRejectionHandler) { - pathSingleSlash { - post { - entity(as[JsonRPCBody]) { - req => - val kit = appKit - import kit._ + authenticateBasic(realm = "Access restricted", userPassAuthenticator) { _ => + pathSingleSlash { + post { + entity(as[JsonRPCBody]) { + req => + val kit = appKit + import kit._ - req.method match { - // utility methods - case "getinfo" => completeRpcFuture(req.id, getInfoResponse) - case "help" => completeRpc(req.id, help) + req.method match { + // utility methods + case "getinfo" => completeRpcFuture(req.id, getInfoResponse) + case "help" => completeRpc(req.id, help) - // channel lifecycle methods - case "connect" => req.params match { - case JString(uri) :: Nil => - completeRpcFuture(req.id, (switchboard ? Peer.Connect(NodeURI.parse(uri))).mapTo[String]) - case _ => reject(UnknownParamsRejection(req.id, "[nodeId, host, port]")) - } - case "open" => req.params match { - case JString(nodeId) :: JInt(fundingSatoshi) :: JInt(pushMsat) :: JInt(flags) :: Nil => - completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(nodeId), Satoshi(fundingSatoshi.toLong), MilliSatoshi(pushMsat.toLong), channelFlags = Some(flags.toByte))).mapTo[String]) - case JString(nodeId) :: JInt(fundingSatoshi) :: JInt(pushMsat) :: Nil => - completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(nodeId), Satoshi(fundingSatoshi.toLong), MilliSatoshi(pushMsat.toLong), channelFlags = None)).mapTo[String]) - case _ => reject(UnknownParamsRejection(req.id, "[nodeId, host, port, fundingSatoshi, pushMsat] or [nodeId, host, port, fundingSatoshi, pushMsat, newChannel]")) - } - case "close" => req.params match { - case JString(identifier) :: Nil => completeRpc(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = None)).mapTo[String]) - case JString(identifier) :: JString(scriptPubKey) :: Nil => completeRpc(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = Some(scriptPubKey))).mapTo[String]) - case _ => reject(UnknownParamsRejection(req.id, "[channelId] or [channelId, scriptPubKey]")) - } - - // local network methods - case "peers" => completeRpcFuture(req.id, for { - peers <- (switchboard ? 'peers).mapTo[Map[PublicKey, ActorRef]] - peerinfos <- Future.sequence(peers.values.map(peer => (peer ? GetPeerInfo).mapTo[PeerInfo])) - } yield peerinfos) - case "channels" => req.params match { - case Nil => completeRpcFuture(req.id, (register ? 'channels).mapTo[Map[Long, ActorRef]].map(_.keys)) - case JString(remoteNodeId) :: Nil => Try(PublicKey(remoteNodeId)) match { - case Success(pk) => completeRpcFuture(req.id, (register ? 'channelsTo).mapTo[Map[BinaryData, PublicKey]].map(_.filter(_._2 == pk).keys)) - case Failure(f) => reject(ValidationRejection(req.id, s"invalid remote node id '$remoteNodeId'")) - } - case _ => reject(UnknownParamsRejection(req.id, "no arguments or [remoteNodeId]")) - } - case "channel" => req.params match { - case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_GETINFO).mapTo[RES_GETINFO]) - case _ => reject(UnknownParamsRejection(req.id, "[channelId]")) - } - - // global network methods - case "allnodes" => completeRpcFuture(req.id, (router ? 'nodes).mapTo[Iterable[NodeAnnouncement]].map(_.map(_.nodeId))) - case "allchannels" => completeRpcFuture(req.id, (router ? 'channels).mapTo[Iterable[ChannelAnnouncement]].map(_.map(c => ChannelInfo(c.shortChannelId.toHexString, c.nodeId1, c.nodeId2)))) - - // payment methods - case "receive" => req.params match { - // only the payment description is given: user may want to generate a donation payment request - case JString(description) :: Nil => - completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(None, description)).mapTo[PaymentRequest].map(PaymentRequest.write)) - // the amount is now given with the description - case JInt(amountMsat) :: JString(description) :: Nil => - completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(Some(MilliSatoshi(amountMsat.toLong)), description)).mapTo[PaymentRequest].map(PaymentRequest.write)) - case _ => reject(UnknownParamsRejection(req.id, "[description] or [amount, description]")) - } - case "send" => req.params match { - // user manually sets the payment information - case JInt(amountMsat) :: JString(paymentHash) :: JString(nodeId) :: Nil => - (Try(BinaryData(paymentHash)), Try(PublicKey(nodeId))) match { - case (Success(ph), Success(pk)) => completeRpcFuture(req.id, (paymentInitiator ? SendPayment(amountMsat.toLong, ph, pk)).mapTo[PaymentResult]) - case (Failure(_), _) => reject(ValidationRejection(req.id, s"invalid payment hash '$paymentHash'")) - case _ => reject(ValidationRejection(req.id, s"invalid node id '$nodeId'")) - } - // user gives a Lightning payment request - case JString(paymentRequest) :: rest => Try(PaymentRequest.read(paymentRequest)) match { - case Success(pr) => - // setting the payment amount - val amount_msat: Long = (pr.amount, rest) match { - // optional amount always overrides the amount in the payment request - case (_, JInt(amount_msat_override) :: Nil) => amount_msat_override.toLong - case (Some(amount_msat_pr), _) => amount_msat_pr.amount - case _ => throw new RuntimeException("you must manually specify an amount for this payment request") - } - logger.debug(s"api call for sending payment with amount_msat=$amount_msat") - // optional cltv expiry - val sendPayment = pr.minFinalCltvExpiry match { - case None => SendPayment(amount_msat, pr.paymentHash, pr.nodeId) - case Some(minFinalCltvExpiry) => SendPayment(amount_msat, pr.paymentHash, pr.nodeId, assistedRoutes = Nil, minFinalCltvExpiry) - } - completeRpcFuture(req.id, (paymentInitiator ? sendPayment).mapTo[PaymentResult]) - case _ => reject(ValidationRejection(req.id, s"payment request is not valid")) + // channel lifecycle methods + case "connect" => req.params match { + case JString(pubkey) :: JString(host) :: JInt(port) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.Connect(NodeURI.parse(s"$pubkey@$host:$port"))).mapTo[String]) + case JString(uri) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.Connect(NodeURI.parse(uri))).mapTo[String]) + case _ => reject(UnknownParamsRejection(req.id, "[nodeId@host:port] or [nodeId, host, port]")) + } + case "open" => req.params match { + case JString(nodeId) :: JInt(fundingSatoshi) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(nodeId), Satoshi(fundingSatoshi.toLong), MilliSatoshi(0), channelFlags = None)).mapTo[String]) + case JString(nodeId) :: JInt(fundingSatoshi) :: JInt(pushMsat) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(nodeId), Satoshi(fundingSatoshi.toLong), MilliSatoshi(pushMsat.toLong), channelFlags = None)).mapTo[String]) + case JString(nodeId) :: JInt(fundingSatoshi) :: JInt(pushMsat) :: JInt(flags) :: Nil => + completeRpcFuture(req.id, (switchboard ? Peer.OpenChannel(PublicKey(nodeId), Satoshi(fundingSatoshi.toLong), MilliSatoshi(pushMsat.toLong), channelFlags = Some(flags.toByte))).mapTo[String]) + case _ => reject(UnknownParamsRejection(req.id, s"[nodeId, fundingSatoshi], [nodeId, fundingSatoshi, pushMsat] or [nodeId, fundingSatoshi, pushMsat, newChannel]")) + } + case "close" => req.params match { + case JString(identifier) :: Nil => completeRpc(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = None)).mapTo[String]) + case JString(identifier) :: JString(scriptPubKey) :: Nil => completeRpc(req.id, sendToChannel(identifier, CMD_CLOSE(scriptPubKey = Some(scriptPubKey))).mapTo[String]) + case _ => reject(UnknownParamsRejection(req.id, "[channelId] or [channelId, scriptPubKey]")) } - case _ => reject(UnknownParamsRejection(req.id, "[amountMsat, paymentHash, nodeId or [paymentRequest] or [paymentRequest, amountMsat]")) - } - // check received payments - case "checkpayment" => req.params match { - case JString(identifier) :: Nil => completeRpcFuture(req.id, for { - paymentHash <- Try(PaymentRequest.read(identifier)) match { - case Success(pr) => Future.successful(pr.paymentHash) - case _ => Try(BinaryData(identifier)) match { - case Success(s) => Future.successful(s) - case _ => Future.failed(new IllegalArgumentException("payment identifier must be a payment request or a payment hash")) + // local network methods + case "peers" => completeRpcFuture(req.id, for { + peers <- (switchboard ? 'peers).mapTo[Map[PublicKey, ActorRef]] + peerinfos <- Future.sequence(peers.values.map(peer => (peer ? GetPeerInfo).mapTo[PeerInfo])) + } yield peerinfos) + case "channels" => req.params match { + case Nil => + val f = for { + channels_id <- (register ? 'channels).mapTo[Map[BinaryData, ActorRef]].map(_.keys) + channels <- Future.sequence(channels_id.map(channel_id => sendToChannel(channel_id.toString(), CMD_GETINFO).mapTo[RES_GETINFO] + .map(gi => LocalChannelInfo(gi.nodeId, gi.channelId, gi.state.toString)))) + } yield channels + completeRpcFuture(req.id, f) + case JString(remoteNodeId) :: Nil => Try(PublicKey(remoteNodeId)) match { + case Success(pk) => + val f = for { + channels_id <- (register ? 'channelsTo).mapTo[Map[BinaryData, PublicKey]].map(_.filter(_._2 == pk).keys) + channels <- Future.sequence(channels_id.map(channel_id => sendToChannel(channel_id.toString(), CMD_GETINFO).mapTo[RES_GETINFO] + .map(gi => LocalChannelInfo(gi.nodeId, gi.channelId, gi.state.toString)))) + } yield channels + completeRpcFuture(req.id, f) + case Failure(_) => reject(RpcValidationRejection(req.id, s"invalid remote node id '$remoteNodeId'")) } - } - found <- (paymentHandler ? CheckPayment(paymentHash)).map(found => new JBool(found.asInstanceOf[Boolean])) - } yield found) - case _ => reject(UnknownParamsRejection(req.id, "[paymentHash] or [paymentRequest]")) - } + case _ => reject(UnknownParamsRejection(req.id, "no arguments or [remoteNodeId]")) + } + case "channel" => req.params match { + case JString(identifier) :: Nil => completeRpcFuture(req.id, sendToChannel(identifier, CMD_GETINFO).mapTo[RES_GETINFO]) + case _ => reject(UnknownParamsRejection(req.id, "[channelId]")) + } - // method name was not found - case _ => reject(UnknownMethodRejection(req.id)) - } + // global network methods + case "allnodes" => completeRpcFuture(req.id, (router ? 'nodes).mapTo[Iterable[NodeAnnouncement]]) + case "allchannels" => completeRpcFuture(req.id, (router ? 'channels).mapTo[Iterable[ChannelAnnouncement]].map(_.map(c => ChannelInfo(c.shortChannelId.toHexString, c.nodeId1, c.nodeId2)))) + case "allupdates" => req.params match { + case JString(nodeId) :: Nil => Try(PublicKey(nodeId)) match { + case Success(pk) => completeRpcFuture(req.id, (router ? 'updatesMap).mapTo[Map[ChannelDesc, ChannelUpdate]].map(_.filter(e => e._1.a == pk || e._1.b == pk).values)) + case Failure(_) => reject(RpcValidationRejection(req.id, s"invalid remote node id '$nodeId'")) + } + case _ => completeRpcFuture(req.id, (router ? 'updates).mapTo[Iterable[ChannelUpdate]]) + } + + // payment methods + case "receive" => req.params match { + // only the payment description is given: user may want to generate a donation payment request + case JString(description) :: Nil => + completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(None, description)).mapTo[PaymentRequest].map(PaymentRequest.write)) + // the amount is now given with the description + case JInt(amountMsat) :: JString(description) :: Nil => + completeRpcFuture(req.id, (paymentHandler ? ReceivePayment(Some(MilliSatoshi(amountMsat.toLong)), description)).mapTo[PaymentRequest].map(PaymentRequest.write)) + case _ => reject(UnknownParamsRejection(req.id, "[description] or [amount, description]")) + } + case "send" => req.params match { + // user manually sets the payment information + case JInt(amountMsat) :: JString(paymentHash) :: JString(nodeId) :: Nil => + (Try(BinaryData(paymentHash)), Try(PublicKey(nodeId))) match { + case (Success(ph), Success(pk)) => completeRpcFuture(req.id, (paymentInitiator ? SendPayment(amountMsat.toLong, ph, pk)).mapTo[PaymentResult]) + case (Failure(_), _) => reject(RpcValidationRejection(req.id, s"invalid payment hash '$paymentHash'")) + case _ => reject(RpcValidationRejection(req.id, s"invalid node id '$nodeId'")) + } + // user gives a Lightning payment request + case JString(paymentRequest) :: rest => Try(PaymentRequest.read(paymentRequest)) match { + case Success(pr) => + // setting the payment amount + val amount_msat: Long = (pr.amount, rest) match { + // optional amount always overrides the amount in the payment request + case (_, JInt(amount_msat_override) :: Nil) => amount_msat_override.toLong + case (Some(amount_msat_pr), _) => amount_msat_pr.amount + case _ => throw new RuntimeException("you must manually specify an amount for this payment request") + } + logger.debug(s"api call for sending payment with amount_msat=$amount_msat") + // optional cltv expiry + val sendPayment = pr.minFinalCltvExpiry match { + case None => SendPayment(amount_msat, pr.paymentHash, pr.nodeId) + case Some(minFinalCltvExpiry) => SendPayment(amount_msat, pr.paymentHash, pr.nodeId, assistedRoutes = Nil, minFinalCltvExpiry) + } + completeRpcFuture(req.id, (paymentInitiator ? sendPayment).mapTo[PaymentResult]) + case _ => reject(RpcValidationRejection(req.id, s"payment request is not valid")) + } + case _ => reject(UnknownParamsRejection(req.id, "[amountMsat, paymentHash, nodeId or [paymentRequest] or [paymentRequest, amountMsat]")) + } + + // check received payments + case "checkpayment" => req.params match { + case JString(identifier) :: Nil => completeRpcFuture(req.id, for { + paymentHash <- Try(PaymentRequest.read(identifier)) match { + case Success(pr) => Future.successful(pr.paymentHash) + case _ => Try(BinaryData(identifier)) match { + case Success(s) => Future.successful(s) + case _ => Future.failed(new IllegalArgumentException("payment identifier must be a payment request or a payment hash")) + } + } + found <- (paymentHandler ? CheckPayment(paymentHash)).map(found => new JBool(found.asInstanceOf[Boolean])) + } yield found) + case _ => reject(UnknownParamsRejection(req.id, "[paymentHash] or [paymentRequest]")) + } + + // method name was not found + case _ => reject(UnknownMethodRejection(req.id)) + } + } } } } @@ -221,13 +256,16 @@ trait Service extends Logging { def help = List( "connect (uri): open a secure connection to a lightning node", - "open (nodeId, fundingSatoshi, pushMsat, channelFlags = 0x01): open a channel with another lightning node", + "connect (nodeId, host, port): open a secure connection to a lightning node", + "open (nodeId, fundingSatoshi, pushMsat = 0, channelFlags = 0x01): open a channel with another lightning node", "peers: list existing local peers", "channels: list existing local channels", "channels (nodeId): list existing local channels to a particular nodeId", "channel (channelId): retrieve detailed information about a given channel", "allnodes: list all known nodes", "allchannels: list all known channels", + "allupdates: list all channels updates", + "allupdates (nodeId): list all channels updates for this nodeId", "receive (amountMsat, description): generate a payment request for a given amount", "send (amountMsat, paymentHash, nodeId): send a payment to a lightning node", "send (paymentRequest): send a payment to a lightning node using a BOLT11 payment request", diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala index c7439b731..1540d6fa1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala @@ -7,7 +7,7 @@ import fr.acinq.eclair.UInt64 import fr.acinq.eclair.crypto.Sphinx import fr.acinq.eclair.transactions.CommitmentSpec import fr.acinq.eclair.transactions.Transactions.CommitTx -import fr.acinq.eclair.wire.{AcceptChannel, AnnouncementSignatures, ChannelAnnouncement, ChannelUpdate, ClosingSigned, FailureMessage, FundingCreated, FundingLocked, FundingSigned, Init, OpenChannel, Shutdown, UpdateAddHtlc} +import fr.acinq.eclair.wire.{AcceptChannel, ChannelAnnouncement, ChannelUpdate, ClosingSigned, FailureMessage, FundingCreated, FundingLocked, FundingSigned, Init, OpenChannel, Shutdown, UpdateAddHtlc} /** @@ -101,7 +101,7 @@ final case class CMD_CLOSE(scriptPubKey: Option[BinaryData]) extends Command case object CMD_GETSTATE extends Command case object CMD_GETSTATEDATA extends Command case object CMD_GETINFO extends Command -final case class RES_GETINFO(nodeid: BinaryData, channelId: BinaryData, state: State, data: Data) +final case class RES_GETINFO(nodeId: BinaryData, channelId: BinaryData, state: State, data: Data) /* 8888888b. d8888 88888888888 d8888 diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala index 47f9e86cd..aa559bf98 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Announcements.scala @@ -5,7 +5,7 @@ import java.net.InetSocketAddress import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey, sha256, verifySignature} import fr.acinq.bitcoin.{BinaryData, Crypto, LexicographicalOrdering} import fr.acinq.eclair.serializationResult -import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, LightningMessageCodecs, NodeAnnouncement} +import fr.acinq.eclair.wire._ import scodec.bits.BitVector import shapeless.HNil @@ -20,8 +20,8 @@ object Announcements { def channelAnnouncementWitnessEncode(chainHash: BinaryData, shortChannelId: Long, nodeId1: PublicKey, nodeId2: PublicKey, bitcoinKey1: PublicKey, bitcoinKey2: PublicKey, features: BinaryData): BinaryData = sha256(sha256(serializationResult(LightningMessageCodecs.channelAnnouncementWitnessCodec.encode(features :: chainHash :: shortChannelId :: nodeId1 :: nodeId2 :: bitcoinKey1 :: bitcoinKey2 :: HNil)))) - def nodeAnnouncementWitnessEncode(timestamp: Long, nodeId: PublicKey, rgbColor: (Byte, Byte, Byte), alias: String, features: BinaryData, addresses: List[InetSocketAddress]): BinaryData = - sha256(sha256(serializationResult(LightningMessageCodecs.nodeAnnouncementWitnessCodec.encode(features :: timestamp :: nodeId :: rgbColor :: alias :: addresses :: HNil)))) + def nodeAnnouncementWitnessEncode(timestamp: Long, nodeId: PublicKey, rgbColor: Color, alias: String, features: BinaryData, addresses: List[InetSocketAddress]): BinaryData = + sha256(sha256(serializationResult(LightningMessageCodecs.nodeAnnouncementWitnessCodec.encode(features :: timestamp :: nodeId :: (rgbColor) :: alias :: addresses :: HNil)))) def channelUpdateWitnessEncode(chainHash: BinaryData, shortChannelId: Long, timestamp: Long, flags: BinaryData, cltvExpiryDelta: Int, htlcMinimumMsat: Long, feeBaseMsat: Long, feeProportionalMillionths: Long): BinaryData = sha256(sha256(serializationResult(LightningMessageCodecs.channelUpdateWitnessCodec.encode(chainHash :: shortChannelId :: timestamp :: flags :: cltvExpiryDelta :: htlcMinimumMsat :: feeBaseMsat :: feeProportionalMillionths :: HNil)))) @@ -59,7 +59,7 @@ object Announcements { ) } - def makeNodeAnnouncement(nodeSecret: PrivateKey, alias: String, color: (Byte, Byte, Byte), addresses: List[InetSocketAddress], timestamp: Long = Platform.currentTime / 1000): NodeAnnouncement = { + def makeNodeAnnouncement(nodeSecret: PrivateKey, alias: String, color: Color, addresses: List[InetSocketAddress], timestamp: Long = Platform.currentTime / 1000): NodeAnnouncement = { require(alias.size <= 32) val witness = nodeAnnouncementWitnessEncode(timestamp, nodeSecret.publicKey, color, alias, "", addresses) val sig = Crypto.encodeSignature(Crypto.sign(witness, nodeSecret)) :+ 1.toByte diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala index 0df65b26a..d9b6dc974 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala @@ -406,6 +406,10 @@ class Router(nodeParams: NodeParams, watcher: ActorRef) extends FSM[State, Data] sender ! (d.updates ++ d.privateUpdates).values stay + case Event('updatesMap, d) => + sender ! (d.updates ++ d.privateUpdates) + stay + case Event('dot, d) => graph2dot(d.nodes, d.channels) pipeTo sender stay @@ -549,7 +553,7 @@ object Router { override def getComponentAttributes(nodeId: PublicKey): java.util.Map[String, String] = nodes.get(nodeId) match { - case Some(ann) => Map("label" -> ann.alias, "color" -> f"#${ann.rgbColor._1}%02x${ann.rgbColor._2}%02x${ann.rgbColor._3}%02x") + case Some(ann) => Map("label" -> ann.alias, "color" -> ann.rgbColor.toString) case None => Map.empty[String, String] } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/LightningMessageCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/LightningMessageCodecs.scala index dc6f6f1b1..9fd2bdd0a 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/LightningMessageCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/LightningMessageCodecs.scala @@ -81,7 +81,7 @@ object LightningMessageCodecs { })) ) - def rgb: Codec[(Byte, Byte, Byte)] = bytes(3).xmap(buf => (buf(0), buf(1), buf(2)), t => ByteVector(t._1, t._2, t._3)) + def rgb: Codec[Color] = bytes(3).xmap(buf => Color(buf(0), buf(1), buf(2)), t => ByteVector(t.r, t.g, t.b)) def zeropaddedstring(size: Int): Codec[String] = fixedSizeBytes(32, utf8).xmap(s => s.takeWhile(_ != '\u0000'), s => s) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/LightningMessageTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/LightningMessageTypes.scala index 200d1e925..99b9efbd2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/LightningMessageTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/LightningMessageTypes.scala @@ -136,11 +136,15 @@ case class ChannelAnnouncement(nodeSignature1: BinaryData, bitcoinKey1: PublicKey, bitcoinKey2: PublicKey) extends RoutingMessage +case class Color(r: Byte, g: Byte, b: Byte) { + override def toString: String = f"#$r%02x$g%02x$b%02x" // to hexa s"# ${r}%02x ${r & 0xFF}${g & 0xFF}${b & 0xFF}" +} + case class NodeAnnouncement(signature: BinaryData, features: BinaryData, timestamp: Long, nodeId: PublicKey, - rgbColor: (Byte, Byte, Byte), + rgbColor: Color, alias: String, // TODO: check address order + support padding data (type 0) addresses: List[InetSocketAddress]) extends RoutingMessage @@ -157,4 +161,4 @@ case class ChannelUpdate(signature: BinaryData, case class PerHopPayload(channel_id: Long, amtToForward: Long, - outgoingCltvValue: Long) \ No newline at end of file + outgoingCltvValue: Long) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index e1bf7d2f1..05e4c1a4c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -8,6 +8,7 @@ import fr.acinq.bitcoin.{BinaryData, Block, DeterministicWallet, Script} import fr.acinq.eclair.NodeParams.BITCOIND import fr.acinq.eclair.db.sqlite._ import fr.acinq.eclair.io.Peer +import fr.acinq.eclair.wire.Color import scala.concurrent.duration._ @@ -31,7 +32,7 @@ object TestConstants { extendedPrivateKey = extendedPrivateKey, privateKey = extendedPrivateKey.privateKey, alias = "alice", - color = (1: Byte, 2: Byte, 3: Byte), + color = Color(1, 2, 3), publicAddresses = new InetSocketAddress("localhost", 9731) :: Nil, globalFeatures = "", localFeatures = "00", @@ -85,7 +86,7 @@ object TestConstants { extendedPrivateKey = extendedPrivateKey, privateKey = extendedPrivateKey.privateKey, alias = "bob", - color = (4: Byte, 5: Byte, 6: Byte), + color = Color(4, 5, 6), publicAddresses = new InetSocketAddress("localhost", 9732) :: Nil, globalFeatures = "", localFeatures = "00", // no announcement diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/electrum/ElectrumClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/electrum/ElectrumClientSpec.scala index 86ba548a9..bef24beea 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/electrum/ElectrumClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/electrum/ElectrumClientSpec.scala @@ -33,7 +33,7 @@ class ElectrumClientSpec extends TestKit(ActorSystem("test")) with FunSuiteLike test("connect to an electrumx testnet server") { probe.send(client, AddStatusListener(probe.ref)) - probe.expectMsg(5 seconds, ElectrumReady) + probe.expectMsg(15 seconds, ElectrumReady) } test("get transaction") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteNetworkDbSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteNetworkDbSpec.scala index c9266d4f8..214dc5e61 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteNetworkDbSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/db/SqliteNetworkDbSpec.scala @@ -7,6 +7,7 @@ import fr.acinq.bitcoin.{Block, Crypto} import fr.acinq.eclair.db.sqlite.SqliteNetworkDb import fr.acinq.eclair.randomKey import fr.acinq.eclair.router.Announcements +import fr.acinq.eclair.wire.Color import org.junit.runner.RunWith import org.scalatest.FunSuite import org.scalatest.junit.JUnitRunner @@ -27,9 +28,9 @@ class SqliteNetworkDbSpec extends FunSuite { val sqlite = inmem val db = new SqliteNetworkDb(sqlite) - val node_1 = Announcements.makeNodeAnnouncement(randomKey, "node-alice", (100.toByte, 200.toByte, 300.toByte), new InetSocketAddress(InetAddress.getByAddress(Array[Byte](192.toByte, 168.toByte, 1.toByte, 42.toByte)), 42000) :: Nil) - val node_2 = Announcements.makeNodeAnnouncement(randomKey, "node-bob", (100.toByte, 200.toByte, 300.toByte), new InetSocketAddress(InetAddress.getByAddress(Array[Byte](192.toByte, 168.toByte, 1.toByte, 42.toByte)), 42000) :: Nil) - val node_3 = Announcements.makeNodeAnnouncement(randomKey, "node-charlie", (100.toByte, 200.toByte, 300.toByte), new InetSocketAddress(InetAddress.getByAddress(Array[Byte](192.toByte, 168.toByte, 1.toByte, 42.toByte)), 42000) :: Nil) + val node_1 = Announcements.makeNodeAnnouncement(randomKey, "node-alice", Color(100.toByte, 200.toByte, 300.toByte), new InetSocketAddress(InetAddress.getByAddress(Array[Byte](192.toByte, 168.toByte, 1.toByte, 42.toByte)), 42000) :: Nil) + val node_2 = Announcements.makeNodeAnnouncement(randomKey, "node-bob", Color(100.toByte, 200.toByte, 300.toByte), new InetSocketAddress(InetAddress.getByAddress(Array[Byte](192.toByte, 168.toByte, 1.toByte, 42.toByte)), 42000) :: Nil) + val node_3 = Announcements.makeNodeAnnouncement(randomKey, "node-charlie", Color(100.toByte, 200.toByte, 300.toByte), new InetSocketAddress(InetAddress.getByAddress(Array[Byte](192.toByte, 168.toByte, 1.toByte, 42.toByte)), 42000) :: Nil) assert(db.listNodes().toSet === Set.empty) db.addNode(node_1) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/BaseRouterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/BaseRouterSpec.scala index 05209179f..75d327380 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/BaseRouterSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/BaseRouterSpec.scala @@ -34,12 +34,12 @@ abstract class BaseRouterSpec extends TestkitBaseClass { //val DUMMY_SIG = BinaryData("3045022100e0a180fdd0fe38037cc878c03832861b40a29d32bd7b40b10c9e1efc8c1468a002205ae06d1624896d0d29f4b31e32772ea3cb1b4d7ed4e077e5da28dcc33c0e781201") - val ann_a = makeNodeAnnouncement(priv_a, "node-A", (15, 10, -70), Nil) - val ann_b = makeNodeAnnouncement(priv_b, "node-B", (50, 99, -80), Nil) - val ann_c = makeNodeAnnouncement(priv_c, "node-C", (123, 100, -40), Nil) - val ann_d = makeNodeAnnouncement(priv_d, "node-D", (-120, -20, 60), Nil) - val ann_e = makeNodeAnnouncement(priv_e, "node-E", (-50, 0, 10), Nil) - val ann_f = makeNodeAnnouncement(priv_f, "node-F", (30, 10, -50), Nil) + val ann_a = makeNodeAnnouncement(priv_a, "node-A", Color(15, 10, -70), Nil) + val ann_b = makeNodeAnnouncement(priv_b, "node-B", Color(50, 99, -80), Nil) + val ann_c = makeNodeAnnouncement(priv_c, "node-C", Color(123, 100, -40), Nil) + val ann_d = makeNodeAnnouncement(priv_d, "node-D", Color(-120, -20, 60), Nil) + val ann_e = makeNodeAnnouncement(priv_e, "node-E", Color(-50, 0, 10), Nil) + val ann_f = makeNodeAnnouncement(priv_f, "node-F", Color(30, 10, -50), Nil) val channelId_ab = toShortId(420000, 1, 0) val channelId_bc = toShortId(420000, 2, 0) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/LightningMessageCodecsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/LightningMessageCodecsSpec.scala index 735aab116..59b886fc8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/LightningMessageCodecsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/LightningMessageCodecsSpec.scala @@ -54,7 +54,7 @@ class LightningMessageCodecsSpec extends FunSuite { } test("encode/decode with rgb codec") { - val color = (47.toByte, 255.toByte, 142.toByte) + val color = Color(47.toByte, 255.toByte, 142.toByte) val bin = rgb.encode(color).require assert(bin === hex"2f ff 8e".toBitVector) val color2 = rgb.decode(bin).require.value @@ -178,7 +178,7 @@ class LightningMessageCodecsSpec extends FunSuite { val commit_sig = CommitSig(randomBytes(32), randomSignature, randomSignature :: randomSignature :: randomSignature :: Nil) val revoke_and_ack = RevokeAndAck(randomBytes(32), scalar(0), point(1)) val channel_announcement = ChannelAnnouncement(randomSignature, randomSignature, randomSignature, randomSignature, bin(7, 9), Block.RegtestGenesisBlock.hash, 1, randomKey.publicKey, randomKey.publicKey, randomKey.publicKey, randomKey.publicKey) - val node_announcement = NodeAnnouncement(randomSignature, bin(0, 0), 1, randomKey.publicKey, (100.toByte, 200.toByte, 300.toByte), "node-alias", new InetSocketAddress(InetAddress.getByAddress(Array[Byte](192.toByte, 168.toByte, 1.toByte, 42.toByte)), 42000) :: Nil) + val node_announcement = NodeAnnouncement(randomSignature, bin(0, 0), 1, randomKey.publicKey, Color(100.toByte, 200.toByte, 300.toByte), "node-alias", new InetSocketAddress(InetAddress.getByAddress(Array[Byte](192.toByte, 168.toByte, 1.toByte, 42.toByte)), 42000) :: Nil) val channel_update = ChannelUpdate(randomSignature, Block.RegtestGenesisBlock.hash, 1, 2, bin(2, 2), 3, 4, 5, 6) val announcement_signatures = AnnouncementSignatures(randomBytes(32), 42, randomSignature, randomSignature) val ping = Ping(100, BinaryData("01" * 10)) diff --git a/eclair-node-gui/src/main/resources/gui/splash/splash.fxml b/eclair-node-gui/src/main/resources/gui/splash/splash.fxml index 5d1425043..fe4e4060f 100644 --- a/eclair-node-gui/src/main/resources/gui/splash/splash.fxml +++ b/eclair-node-gui/src/main/resources/gui/splash/splash.fxml @@ -40,7 +40,7 @@