Add proper parsing of bitcoin.conf, rework auth credentials (#478)

* Add proper parsing of bitcoin.conf, remove datadir

Somewhat recently a new config format was introduced
in Bitcoin Core. Options can now be specified under
section headers ([regtest], [main], [test]), as
well as with network prefixes (regtest.rpcport,
main.prune) and the traditional format. In this commit
we introduce a new type BitcoindConfig that parses
this format. We also make the necessary changes in
the bitcoind/Eclair RPC clients to accomodate this new
type.

We also remove the datadir field from
BitcoindAuthCredentials. This is not strictly necessary
to connect to a bitcoind, so this is a bad separation
of concerns. The datadir field is instead moved into
the BitcoindInstance.

Finally we go over some places in tests and test
utils and assert that we are operating in the
user temporary directory. This is a safety measure
so that other developers/users won't do the same
mistake I did while working on this and accidentally
blow up their $HOME/.bitcoin directory.

* Add BitcoindRpcClient.fromDatadir

* Address code review from Chris
This commit is contained in:
Torkel Rogstad 2019-05-28 18:33:16 +02:00 committed by Chris Stewart
parent c07ae36076
commit fb178eb295
20 changed files with 1086 additions and 359 deletions

View file

@ -9,13 +9,19 @@ import org.bitcoins.testkit.rpc.BitcoindRpcTestUtil
import org.bitcoins.testkit.util.BitcoindRpcTest
import scala.io.Source
import akka.stream.StreamTcpException
import java.nio.file.Paths
import scala.util.Properties
import org.bitcoins.rpc.config.BitcoindConfig
import org.bitcoins.rpc.config.BitcoindAuthCredentials
import org.bitcoins.rpc.util.RpcUtil
import org.bitcoins.core.config.RegTest
import java.net.URI
class BitcoindInstanceTest extends BitcoindRpcTest {
private val source =
Source.fromURL(getClass.getResource("/sample-bitcoin.conf"))
private val sampleConf: Seq[String] = {
val source = Source.fromURL(getClass.getResource("/sample-bitcoin.conf"))
source.getLines.toSeq
}
@ -28,8 +34,106 @@ class BitcoindInstanceTest extends BitcoindRpcTest {
pw.close()
}
override def afterAll(): Unit = {}
def addDatadirAndWrite(conf: BitcoindConfig): BitcoindConfig = {
val tempDir = Files.createTempDirectory("")
val confWithDatadir = conf.datadir match {
case None =>
conf.withOption("datadir", tempDir.toString)
case Some(value) => conf
}
val tempfile = Paths.get(Properties.tmpDir, "bitcoin.conf")
BitcoindRpcTestUtil.writeConfigToFile(confWithDatadir)
confWithDatadir
}
behavior of "BitcoindInstance"
it should "start a bitcoind with cookie based authentication" in {
val confStr = s"""
|regtest=1
|daemon=1
|port=${RpcUtil.randomPort}
|rpcport=${RpcUtil.randomPort}
""".stripMargin
val conf = addDatadirAndWrite(BitcoindConfig(confStr))
val instance = BitcoindInstance.fromConfig(conf)
assert(
instance.authCredentials
.isInstanceOf[BitcoindAuthCredentials.CookieBased])
for {
cli <- BitcoindRpcTestUtil.startedBitcoindRpcClient(instance,
clientAccum =
clientAccum)
_ <- cli.getBalance
} yield succeed
}
it should "start a bitcoind with user and password based authentication" in {
val confStr = s"""
|daemon=1
|regtest=1
|rpcuser=foobar
|rpcpassword=barfoo
|port=${RpcUtil.randomPort}
|rpcport=${RpcUtil.randomPort}
""".stripMargin
val conf = addDatadirAndWrite(BitcoindConfig(confStr))
val instance = BitcoindInstance.fromConfig(conf)
assert(
instance.authCredentials
.isInstanceOf[BitcoindAuthCredentials.PasswordBased])
for {
cli <- BitcoindRpcTestUtil.startedBitcoindRpcClient(instance,
clientAccum =
clientAccum)
_ <- cli.getBalance
} yield succeed
}
// the values in this conf was generated by executing
// rpcauth.py from Bicoin Core like this:
//
// ./rpcauth.py bitcoin-s strong_password
// String to be appended to bitcoin.conf:
// rpcauth=bitcoin-s:6d7580be1deb4ae52bc4249871845b09$82b282e7c6493f6982a5a7af9fbb1b671bab702e2f31bbb1c016bb0ea1cc27ca
// Your password:
// strong_password
it should "start a bitcoind with auth based authentication" in {
val port = RpcUtil.randomPort
val rpcPort = RpcUtil.randomPort
val confStr = s"""
|daemon=1
|rpcauth=bitcoin-s:6d7580be1deb4ae52bc4249871845b09$$82b282e7c6493f6982a5a7af9fbb1b671bab702e2f31bbb1c016bb0ea1cc27ca
|regtest=1
|port=${RpcUtil.randomPort}
|rpcport=${RpcUtil.randomPort}
""".stripMargin
val conf = addDatadirAndWrite(BitcoindConfig(confStr))
val authCredentials =
BitcoindAuthCredentials.PasswordBased(username = "bitcoin-s",
password = "strong_password")
val instance =
BitcoindInstance(
network = RegTest,
uri = new URI(s"http://localhost:$port"),
rpcUri = new URI(s"http://localhost:$rpcPort"),
authCredentials = authCredentials,
datadir = conf.datadir.get
)
for {
cli <- BitcoindRpcTestUtil.startedBitcoindRpcClient(instance,
clientAccum =
clientAccum)
_ <- cli.getBalance
} yield succeed
}
it should "parse a bitcoin.conf file, start bitcoind, mine some blocks and quit" in {
val instance = BitcoindInstance.fromDatadir(datadir.toFile)
val client = new BitcoindRpcClient(instance)
@ -39,6 +143,15 @@ class BitcoindInstanceTest extends BitcoindRpcTest {
_ <- client.generate(101)
balance <- client.getBalance
_ <- BitcoindRpcTestUtil.stopServers(Vector(client))
_ <- client.getBalance
.map { balance =>
logger.error(s"Got unexpected balance: $balance")
fail("Was able to connect to bitcoind after shutting down")
}
.recover {
case _: StreamTcpException =>
()
}
} yield assert(balance > Bitcoins(0))
}

View file

@ -109,8 +109,9 @@ class TestRpcUtilTest extends BitcoindRpcTest {
it should "create a temp bitcoin directory when creating a DaemonInstance, and then delete it" in {
val instance =
BitcoindRpcTestUtil.instance(RpcUtil.randomPort, RpcUtil.randomPort)
val dir = instance.authCredentials.datadir
val dir = instance.datadir
assert(dir.isDirectory)
assert(dir.getPath().startsWith(scala.util.Properties.tmpDir))
assert(
dir.listFiles.contains(new File(dir.getAbsolutePath + "/bitcoin.conf")))
BitcoindRpcTestUtil.deleteTmpDir(dir)
@ -131,8 +132,8 @@ class TestRpcUtilTest extends BitcoindRpcTest {
it should "be able to create a connected node pair with more than 100 blocks and then delete them" in {
for {
(client1, client2) <- BitcoindRpcTestUtil.createNodePair()
_ = assert(client1.getDaemon.authCredentials.datadir.isDirectory)
_ = assert(client2.getDaemon.authCredentials.datadir.isDirectory)
_ = assert(client1.getDaemon.datadir.isDirectory)
_ = assert(client2.getDaemon.datadir.isDirectory)
nodes <- client1.getAddedNodeInfo(client2.getDaemon.uri)
_ = assert(nodes.nonEmpty)
@ -143,8 +144,8 @@ class TestRpcUtilTest extends BitcoindRpcTest {
_ = assert(count2 > 100)
_ <- BitcoindRpcTestUtil.deleteNodePair(client1, client2)
} yield {
assert(!client1.getDaemon.authCredentials.datadir.exists)
assert(!client2.getDaemon.authCredentials.datadir.exists)
assert(!client1.getDaemon.datadir.exists)
assert(!client2.getDaemon.datadir.exists)
}
}

View file

@ -17,6 +17,7 @@ import org.bitcoins.testkit.rpc.BitcoindRpcTestUtil
import org.bitcoins.testkit.util.BitcoindRpcTest
import scala.concurrent.Future
import java.nio.file.Path
class MempoolRpcTest extends BitcoindRpcTest {
lazy val clientsF: Future[(BitcoindRpcClient, BitcoindRpcClient)] =
@ -27,20 +28,15 @@ class MempoolRpcTest extends BitcoindRpcTest {
case (client, otherClient) =>
val defaultConfig = BitcoindRpcTestUtil.standardConfig
val datadirValue = {
val datadir: Path = {
val tempDirPrefix = null // because java APIs are bad
val tempdirPath = Files.createTempDirectory(tempDirPrefix).toString
ConfigValueFactory.fromAnyRef(tempdirPath)
Files.createTempDirectory(tempDirPrefix)
}
// walletbroadcast must be turned off for a transaction to be abondonable
val noBroadcastValue = ConfigValueFactory.fromAnyRef(0)
// connecting clients once they are started takes forever for some reason
val configNoBroadcast =
defaultConfig
.withValue("walletbroadcast", noBroadcastValue)
.withValue("datadir", datadirValue)
.withOption("datadir", datadir.toString())
.withOption("walletbroadcast", 0.toString)
val _ = BitcoindRpcTestUtil.writeConfigToFile(configNoBroadcast)
@ -193,7 +189,7 @@ class MempoolRpcTest extends BitcoindRpcTest {
(client, _) <- clientsF
regTest = {
val regTest =
new File(client.getDaemon.authCredentials.datadir + "/regtest")
new File(client.getDaemon.datadir + "/regtest")
assert(regTest.isDirectory)
assert(!regTest.list().contains("mempool.dat"))
regTest

View file

@ -60,7 +60,7 @@ class WalletRpcTest extends BitcoindRpcTest {
for {
(client, _, _) <- clientsF
result <- {
val datadir = client.getDaemon.authCredentials.datadir
val datadir = client.getDaemon.datadir
client.dumpWallet(datadir + "/test.dat")
}
} yield {
@ -87,11 +87,11 @@ class WalletRpcTest extends BitcoindRpcTest {
for {
(client, _, _) <- clientsF
_ <- {
val datadir = client.getDaemon.authCredentials.datadir
val datadir = client.getDaemon.datadir
client.backupWallet(datadir + "/backup.dat")
}
} yield {
val datadir = client.getDaemon.authCredentials.datadir
val datadir = client.getDaemon.datadir
val file = new File(datadir + "/backup.dat")
assert(file.exists)
assert(file.isFile)
@ -369,8 +369,7 @@ class WalletRpcTest extends BitcoindRpcTest {
_ <- client.importPrivKey(ecPrivateKey, rescan = false)
key <- client.dumpPrivKey(address)
result <- client
.dumpWallet(
client.getDaemon.authCredentials.datadir + "/wallet_dump.dat")
.dumpWallet(client.getDaemon.datadir + "/wallet_dump.dat")
} yield {
assert(key == ecPrivateKey)
val reader = new Scanner(result.filename)
@ -426,7 +425,7 @@ class WalletRpcTest extends BitcoindRpcTest {
(client, _, _) <- clientsF
walletClient <- walletClientF
address <- client.getNewAddress
walletFile = client.getDaemon.authCredentials.datadir + "/client_wallet.dat"
walletFile = client.getDaemon.datadir + "/client_wallet.dat"
fileResult <- client.dumpWallet(walletFile)
_ <- walletClient.walletPassphrase(password, 1000)

View file

@ -0,0 +1,60 @@
package org.bitcoins.rpc.config
import org.bitcoins.testkit.util.BitcoinSUnitTest
import org.bitcoins.rpc.config.BitcoindAuthCredentials.CookieBased
import org.bitcoins.rpc.config.BitcoindAuthCredentials.PasswordBased
import org.bitcoins.core.config.RegTest
class BitcoindAuthCredentialsTest extends BitcoinSUnitTest {
it must "handle cookie based auth" in {
val confStr = """
|regtest=1
""".stripMargin
val conf = BitcoindConfig(confStr)
val auth = BitcoindAuthCredentials.fromConfig(conf)
val cookie = auth match {
case cookie: CookieBased => cookie
case _: PasswordBased =>
fail("got password based")
}
assert(conf.network == RegTest)
assert(cookie.cookiePath.toString().contains("regtest"))
}
it must "default to password based auth" in {
val confStr = """
|regtest=1
|rpcuser=foo
|rpcpassword=bar
""".stripMargin
val conf = BitcoindConfig(confStr)
val auth = BitcoindAuthCredentials.fromConfig(conf)
val pass = auth match {
case _: CookieBased => fail("got cookie")
case pass: PasswordBased => pass
}
assert(conf.network == RegTest)
assert(pass.password == "bar")
assert(pass.username == "foo")
}
it must "handle password based auth" in {
val confStr = """
|regtest=1
|rpcuser=foo
|rpcpassword=bar
""".stripMargin
val conf = BitcoindConfig(confStr)
BitcoindAuthCredentials.fromConfig(conf) match {
case _: CookieBased => fail
case PasswordBased(username, password) =>
assert(username == "foo")
assert(password == "bar")
}
}
}

View file

@ -0,0 +1,144 @@
package org.bitcoins.rpc.config
import org.bitcoins.testkit.util.BitcoinSUnitTest
import org.bitcoins.core.config.RegTest
import org.bitcoins.core.config.TestNet3
import org.bitcoins.core.config.MainNet
import org.bitcoins.testkit.rpc.BitcoindRpcTestUtil
import org.bitcoins.testkit.util.BitcoindRpcTest
class BitcoindConfigTest extends BitcoinSUnitTest {
it must "have to/fromString symmetry" in {
val conf = BitcoindRpcTestUtil.standardConfig
val confStr = conf.toWriteableString
val otherConf = BitcoindConfig(confStr)
val otherConfStr = otherConf.toWriteableString
assert(confStr == otherConfStr)
}
it must "parse networks" in {
val conf = BitcoindConfig("""
|regtest=1
""".stripMargin)
assert(conf.network == RegTest)
}
it must "prioritize a prefixed option" in {
val confStr = """
|regtest=1
|
|rpcport=2000
|regtest.rpcport=3000
|
|[regtest]
|rpcport=4000
""".stripMargin.split("\n")
val conf = BitcoindConfig(confStr)
assert(conf.rpcport == 3000)
assert(conf.network == RegTest)
}
it must "avoid to get prefixed options in section headers" in {
val confStr = """
|regtest=1
|
|rpcport=2000
|
|[regtest]
|rpcport=4000
|
|regtest.rpcport=3000
""".stripMargin.split("\n")
val conf = BitcoindConfig(confStr)
assert(conf.rpcport == 4000)
assert(conf.network == RegTest)
}
it must "avoid getting options for the wrong network" in {
val confStr = """
|testnet=1
|
|[regtest]
|rpcport=4000
|
|regtest.rpcport=3000
""".stripMargin.split("\n")
val conf = BitcoindConfig(confStr)
assert(conf.rpcport == TestNet3.rpcPort)
assert(conf.network == TestNet3)
}
it must "get options with multiple header sections" in {
val confStr = """
|testnet=1
|
|[test]
|rpcuser=username
|rpcport=3000
|
|[regtest]
|rpcport=4000
|
|[main]
|rpcport=1000
""".stripMargin.split("\n")
val conf = BitcoindConfig(confStr)
assert(conf.rpcport == 3000)
assert(conf.network == TestNet3)
assert(conf.username.contains("username"))
}
it must "fallback to default values" in {
val conf = BitcoindConfig.empty
assert(conf.network == MainNet)
assert(conf.rpcport == MainNet.rpcPort)
assert(conf.password.isEmpty)
assert(conf.username.isEmpty)
}
it must "get options with multiple header sections when our section is the last" in {
val confStr = """
|regtest=1
|
|[test]
|rpcport=3000
|
|[main]
|rpcport=1000
|[regtest]
|rpcport=4000
|rpcuser=username
""".stripMargin.split("\n")
val conf = BitcoindConfig(confStr)
assert(conf.rpcport == 4000)
assert(conf.network == RegTest)
assert(conf.username.contains("username"))
}
it must "have a default config in test utils" in {
val conf = BitcoindRpcTestUtil.standardConfig
assert(conf.username.isDefined)
assert(conf.password.isDefined)
assert(conf.zmqpubhashblock.isDefined)
assert(conf.zmqpubhashtx.isDefined)
assert(conf.zmqpubrawblock.isDefined)
assert(conf.zmqpubrawtx.isDefined)
assert({
conf.rpcUri // cal by name
true
})
assert({
conf.uri // cal by name
true
})
}
}

View file

@ -20,6 +20,7 @@ import scala.async.Async.{async, await}
import scala.concurrent.Future
import scala.concurrent.duration.DurationInt
import scala.util.Properties
import org.bitcoins.rpc.client.common.BitcoindVersion
class BitcoindV16RpcClientTest extends BitcoindRpcTest {
lazy val clientsF: Future[(BitcoindV16RpcClient, BitcoindV16RpcClient)] =
@ -27,6 +28,15 @@ class BitcoindV16RpcClientTest extends BitcoindRpcTest {
behavior of "BitoindV16RpcClient"
it should "be able to start a V16 bitcoind" in {
for {
(client, otherClient) <- clientsF
} yield {
assert(client.version == BitcoindVersion.V16)
assert(otherClient.version == BitcoindVersion.V16)
}
}
it should "be able to sign a raw transaction" in {
for {
(client, otherClient) <- clientsF

View file

@ -2,6 +2,9 @@ package org.bitcoins.rpc.client.common
import akka.actor.ActorSystem
import org.bitcoins.rpc.config.BitcoindInstance
import scala.concurrent.Future
import java.io.File
import org.bitcoins.rpc.config.BitcoindConfig
/**
* This class is not guaranteed to be compatible with any particular
@ -35,6 +38,20 @@ class BitcoindRpcClient(val instance: BitcoindInstance)(
}
object BitcoindRpcClient {
/**
* Constructs a RPC client from the given datadir, or
* the default datadir if no directory is provided
*/
def fromDatadir(datadir: File = BitcoindConfig.DEFAULT_DATADIR)(
implicit system: ActorSystem): BitcoindRpcClient = {
val instance = BitcoindInstance.fromDatadir(datadir)
val cli = new BitcoindRpcClient(instance)
cli
}
}
sealed trait BitcoindVersion
object BitcoindVersion {

View file

@ -1,6 +1,5 @@
package org.bitcoins.rpc.client.common
import java.io.File
import java.util.UUID
import akka.actor.ActorSystem
@ -19,9 +18,13 @@ import play.api.libs.json._
import scala.concurrent._
import scala.concurrent.duration.DurationInt
import scala.io.Source
import scala.sys.process._
import scala.util.{Failure, Success}
import java.nio.file.Files
import org.bitcoins.rpc.config.BitcoindAuthCredentials.CookieBased
import org.bitcoins.rpc.config.BitcoindAuthCredentials.PasswordBased
import java.nio.file.Path
import org.bitcoins.rpc.config.BitcoindAuthCredentials
/**
* This is the base trait for Bitcoin Core
@ -38,16 +41,20 @@ trait Client extends BitcoinSLogger {
/**
* The log file of the Bitcoin Core daemon
*/
def logFile: File = {
lazy val logFile: Path = {
val prefix = instance.network match {
case MainNet => ""
case TestNet3 => "/testnet"
case RegTest => "/regtest"
case TestNet3 => "testnet"
case RegTest => "regtest"
}
val datadir = instance.authCredentials.datadir
new File(datadir + prefix + "/debug.log")
instance.datadir.toPath.resolve(prefix).resolve("debug.log")
}
/** The configuration file of the Bitcoin Core daemon */
lazy val confFile: Path =
instance.datadir.toPath.resolve("bitcoin.conf")
protected implicit val system: ActorSystem
protected implicit val materializer: ActorMaterializer =
ActorMaterializer.create(system)
@ -88,10 +95,12 @@ trait Client extends BitcoinSLogger {
val binaryPath = instance.binary.getAbsolutePath
val cmd = List(binaryPath,
"-datadir=" + instance.authCredentials.datadir,
"-datadir=" + instance.datadir,
"-rpcport=" + instance.rpcUri.getPort,
"-port=" + instance.uri.getPort)
logger.debug(s"starting bitcoind")
logger.debug(
s"starting bitcoind with datadir ${instance.datadir} and binary path $binaryPath")
val _ = Process(cmd).run()
def isStartedF: Future[Boolean] = {
@ -106,19 +115,55 @@ trait Client extends BitcoinSLogger {
started.future
}
val started = AsyncUtil.retryUntilSatisfiedF(() => isStartedF,
duration = 1.seconds,
maxTries = 60)
// if we're doing cookie based authentication, we might attempt
// to read the cookie file before it's written. this ensures
// we avoid that
val awaitCookie: BitcoindAuthCredentials => Future[Unit] = {
case cookie: CookieBased =>
val cookieExistsF =
AsyncUtil.retryUntilSatisfied(Files.exists(cookie.cookiePath))
cookieExistsF.onComplete {
case Failure(exception) =>
logger.error(s"Cookie filed was never created! $exception")
case _: Success[_] =>
}
cookieExistsF
case _: PasswordBased => Future.successful(())
}
val started = {
for {
_ <- awaitCookie(instance.authCredentials)
_ <- AsyncUtil.retryUntilSatisfiedF(() => isStartedF,
duration = 1.seconds,
maxTries = 60)
} yield ()
}
started.onComplete {
case Success(_) => logger.debug(s"started bitcoind")
case Failure(exc) =>
if (instance.network != MainNet) {
logger.info(
s"Could not start bitcoind instance! Message: ${exc.getMessage}")
logger.info(s"Log lines:")
val logLines = Source.fromFile(logFile).getLines()
logLines.foreach(logger.info)
logger.info(
s"Could not start bitcoind instance! Message: ${exc.getMessage}")
// When we're unable to start bitcoind that's most likely
// either a configuration error or bug in Bitcoin-S. In either
// case it's much easier to debug this with conf and logs
// dumped somewhere. Especially in tests this is
// convenient, as our test framework deletes the data directories
// of our instances. We don't want to do this on mainnet,
// as both the logs and conf file most likely contain sensitive
// information
if (network != MainNet) {
val tempfile = Files.createTempFile("bitcoind-log-", ".dump")
val logfile = Files.readAllBytes(logFile)
Files.write(tempfile, logfile)
logger.info(s"Dumped debug.log to $tempfile")
val otherTempfile = Files.createTempFile("bitcoin-conf-", ".dump")
val conffile = Files.readAllBytes(confFile)
Files.write(otherTempfile, conffile)
logger.info(s"Dumped bitcoin.conf to $otherTempfile")
}
}

View file

@ -1,45 +1,100 @@
package org.bitcoins.rpc.config
import java.io.File
import org.bitcoins.core.util.BitcoinSLogger
import java.nio.file.Paths
import org.bitcoins.core.config.TestNet3
import org.bitcoins.core.config.MainNet
import org.bitcoins.core.config.RegTest
import java.nio.file.Files
import org.bitcoins.core.config.NetworkParameters
/**
* Created by chris on 5/2/17.
* This trait contains the information we need to authenticate
* to a `bitcoind` node.
*/
sealed trait BitcoindAuthCredentials {
/** The directory where our bitcoin.conf file is located */
def datadir: File
/** rpcusername field in our bitcoin.conf file */
def username: String
/** rpcpassword field in our bitcoin.conf file */
def password: String
def rpcPort: Int
def username: String
}
object BitcoindAuthCredentials {
private case class BitcoindAuthCredentialsImpl(
username: String,
password: String,
rpcPort: Int,
datadir: File)
extends BitcoindAuthCredentials
object BitcoindAuthCredentials extends BitcoinSLogger {
import scala.collection.JavaConverters._
def apply(
/**
* Authenticate by providing a username and password.
* If you are connecting to a local `bitcoind` you
* should instead use cookie based authentication.
* If you are connecting to a remote `bitcoind`, you
* should use the Bitcoin Core-provided script
* `rpcauth.py` to generate credentials. This will
* give you a `rpcauth=...` string you can put in
* your remote `bitcoind` configuration, as well as
* a set of `rpcuser=...` and `rpcpassword=...` you
* can put in your local `bitcoin.conf` configuration
* file or provide directly to this class.
*
* @see [[https://github.com/bitcoin/bitcoin/tree/master/share/rpcauth rpcauth.py]],
* canonical Python script provided by Bitcoin Core to generate the
* auth credentials.
*/
case class PasswordBased(
username: String,
password: String,
rpcPort: Int): BitcoindAuthCredentials = {
val defaultDataDir = new File(System.getProperty("user.home") + "/.bitcoin")
BitcoindAuthCredentials(username, password, rpcPort, defaultDataDir)
password: String
) extends BitcoindAuthCredentials
/**
* Authenticate by providing a cookie file
* found in the `bitcoind` data directory.
* This is the most secure as well as user
* friendly way of authenticating, but it
* is not always suitable for situtations
* where the `bitcoind` instance is on a
* remote server.
*/
case class CookieBased(
network: NetworkParameters,
datadir: File = BitcoindConfig.DEFAULT_DATADIR)
extends BitcoindAuthCredentials {
lazy private[bitcoins] val cookiePath = {
val middleSegment = network match {
case TestNet3 => "testnet3"
case MainNet => ""
case RegTest => "regtest"
}
Paths.get(datadir.toString, middleSegment, ".cookie")
}
/**
* The cookie is a string looking like
* `__cookie__:AUTO_GENERATED_PASSWORD`
*/
def cookie: String = {
if (Files.exists(cookiePath)) {
val cookieLines = Files.readAllLines(cookiePath).asScala
cookieLines.head
} else {
throw new RuntimeException(s"Could not find $cookiePath!")
}
}
def username: String = cookie.split(":").head
def password: String = cookie.split(":").last
}
def apply(
username: String,
password: String,
rpcPort: Int,
datadir: File): BitcoindAuthCredentials = {
BitcoindAuthCredentialsImpl(username, password, rpcPort, datadir)
def fromConfig(config: BitcoindConfig): BitcoindAuthCredentials = {
val datadir = config.datadir.getOrElse(BitcoindConfig.DEFAULT_DATADIR)
val username = config.username
val password = config.password
(username, password) match {
case (Some(user), Some(pass)) =>
PasswordBased(user, pass)
case (_, _) =>
CookieBased(config.network, datadir = datadir)
}
}
}

View file

@ -0,0 +1,324 @@
package org.bitcoins.rpc.config
import org.bitcoins.core.util.BitcoinSLogger
import org.bitcoins.core.config._
import java.io.File
import java.nio.file.Files
import scala.util.Properties
import java.nio.file.Paths
import java.net.URI
import java.nio.file.Path
/**
* This class represents a parsed `bitcoin.conf` file. It
* respects the different ways of writing options in
* `bitcoin.conf`: Raw options, network-prefixed options
* and options within network sections. It also tries to
* conform to the way Bitcoin Core gives precedence to the
* different properties.
*
* Not all options are exposed from this class. We only
* expose those that are of relevance when making RPC
* requests.
*
* @see https://github.com/bitcoin/bitcoin/blob/master/doc/bitcoin-conf.md
*/
abstract class BitcoindConfig extends BitcoinSLogger {
private[bitcoins] def lines: Seq[String]
/**
* Converts the config back to a string that can be written
* to file, and passed to `bitcoind`
*/
lazy val toWriteableString: String = lines.mkString("\n")
/** The optional index of the first header section encountered */
private lazy val firstHeaderSectionIndex: Option[Int] = {
val indices =
List(RegTest, TestNet3, MainNet).map(headerSectionIndex).flatten
if (indices.nonEmpty) Some(indices.min) else None
}
/** The optional index of the header section for the given network */
private def headerSectionIndex(network: NetworkParameters): Option[Int] =
lines.indexOf(s"[${networkString(network)}]") match {
case -1 => None
case other: Int => Some(other)
}
private def networkString(network: NetworkParameters) =
network match {
case MainNet => "main"
case RegTest => "regtest"
case TestNet3 => "test"
}
/** The networks we're _not_ on */
private lazy val otherNetworks = network match {
case MainNet => List("test", "regtest")
case RegTest => List("main", "test")
case TestNet3 => List("main", "regtest")
}
private lazy val ourNetworkString: String = networkString(network)
/** Splits the provided lines into pairs of keys/values
* based on `=`, and then applies the provided
* `collect` function on those pairs
*/
private def collectFrom(lines: Seq[String])(
collect: PartialFunction[(String, String), String]): Seq[String] = {
val splittedPairs = {
val splitLines = lines.map(
_.split("=")
.map(_.trim)
.toList)
splitLines.collect {
case h :: t :: _ => h -> t
}
}
splittedPairs.collect(collect)
}
/**
* Applies the given partial function to all key/value pairs
* found in this config
*/
private val collectAllLines: PartialFunction[(String, String), String] => Seq[
String] = collectFrom(lines)(_)
/** The blockchain network associated with this `bitcoind` config */
lazy val network = {
val networkStrOpt =
collectAllLines {
case (network @ ("testnet" | "regtest" | "mainnet"), "1") => network
}.lastOption
val networkOpt = networkStrOpt.flatMap(Networks.fromString)
(networkOpt, networkStrOpt) match {
case (None, Some(badStr)) =>
logger.warn(
s"'$badStr' is not a valid Bitcoin network! Defaulting to mainnet")
case _ =>
}
networkOpt.getOrElse(MainNet)
}
/**
* First searches for option prefixed with network,
* then section with header
* and lastly just raw option
*/
private[config] def getValue(key: String): Option[String] =
readPrefixOpt(key)
.orElse(readSectionHeaderOpt(key))
.orElse(readRawOpt(key))
/** Searches the config for a key matching the provided
* string that's prefixed by the network we're currently
* on
*/
private def readPrefixOpt(key: String): Option[String] = {
val prefixedOptKey = s"$ourNetworkString.$key"
val collect = firstHeaderSectionIndex match {
case None => collectAllLines(_)
case Some(index) => collectFrom(lines.take(index))(_)
}
collect {
case (`prefixedOptKey`, value) => value
}.headOption
}
/**
* Searches the config for a key matching the provided
* string that's under a section header matching the
* network we're on.
*/
private def readSectionHeaderOpt(key: String): Option[String] = {
val startIndexOpt = lines.indexOf(s"[$ourNetworkString]") match {
case -1 =>
None
case other: Int => Some(other)
}
for {
startIndex <- startIndexOpt
endIndex = {
// we're looking for the first section _after_ the
// section for our current network
val toSearchIn = lines.zipWithIndex.drop(startIndex)
toSearchIn
.find {
case (line, _) =>
otherNetworks.exists(net => s"[$net]" == line)
}
.map {
case (_, index) => index
}
// we got to the end without finding a new section header,
// that means we can search to the bottom
.getOrElse(lines.length)
}
result <- {
val linesToSearchIn = lines.slice(startIndex, endIndex)
val collect = collectFrom(linesToSearchIn)(_)
collect {
case (`key`, value) => value
}.headOption
}
} yield result
}
/**
* Searches the config for a key matchinng the provided
* string that's _not_ in a section header or prefixed
* by a network.
*/
private def readRawOpt(key: String): Option[String] = {
val linesToSearchIn = lines.takeWhile(!_.startsWith("["))
val collect = collectFrom(linesToSearchIn)(_)
collect {
case (`key`, value) => value
}.headOption
}
lazy val datadir: Option[File] = getValue("datadir").map(new File(_))
lazy val username: Option[String] = getValue("rpcuser")
lazy val password: Option[String] = getValue("rpcpassword")
lazy val zmqpubrawblock: Option[URI] =
getValue("zmqpubrawblock").map(new URI(_))
lazy val zmqpubrawtx: Option[URI] = getValue("zmqpubrawtx").map(new URI(_))
lazy val zmqpubhashblock: Option[URI] =
getValue("zmqpubhashblock").map(new URI(_))
lazy val zmqpubhashtx: Option[URI] = getValue("zmqpubhashtx").map(new URI(_))
lazy val port: Int = getValue("port").map(_.toInt).getOrElse(network.port)
/** Defaults to localhost */
lazy val bind: URI = new URI({
val baseUrl = getValue("bind").getOrElse("localhost")
if (baseUrl.startsWith("http")) baseUrl
else "http://" + baseUrl
})
lazy val uri: URI = new URI(s"$bind:$port")
lazy val rpcport: Int =
getValue("rpcport").map(_.toInt).getOrElse(network.rpcPort)
/** Defaults to localhost */
lazy val rpcbind: URI = new URI({
val baseUrl = getValue("rpcbind").getOrElse("localhost")
if (baseUrl.startsWith("http")) baseUrl
else "http://" + baseUrl
})
lazy val rpcUri: URI = new URI(s"$rpcbind:$rpcport")
/** Creates a new config with the given keys and values appended */
def withOption(key: String, value: String): BitcoindConfig = {
val ourLines = this.lines
new BitcoindConfig {
def lines: Seq[String] = {
val newLine = s"$key=$value"
newLine +: ourLines
}
}
}
/** Creates a new config with the given key and values,
* with the given network prefixed to the key
*
* Old config:
* {{{
* rpcport=4000
* }}}
*
* New config:
* {{{
* withOption("rpcport", "2000", MainNet) =
* main.rpcport=2000
* rpcport=4000
* }}}
*/
def withOption(
key: String,
value: String,
network: NetworkParameters): BitcoindConfig =
withOption(key = s"${networkString(network)}.$key", value = value)
}
object BitcoindConfig {
/** The empty `bitcoind` config */
lazy val empty: BitcoindConfig = BitcoindConfig("")
/** Constructs a `bitcoind` config from the given lines */
def apply(config: Seq[String]): BitcoindConfig = new BitcoindConfig {
val lines: Seq[String] = config
}
/**
* Constructs a `bitcoind` config from the given string,
* by splitting it on newlines
*/
def apply(config: String): BitcoindConfig =
apply(config.split("\n"))
/** Reads the given path and construct a `bitcoind` config from it */
def apply(config: Path): BitcoindConfig = apply(config.toFile)
/** Reads the given file and construct a `bitcoind` config from it */
def apply(config: File): BitcoindConfig = {
import scala.collection.JavaConverters._
val lines = Files
.readAllLines(config.toPath)
.iterator()
.asScala
.toList
apply(lines)
}
/**
* If there is a `bitcoin.conf` in the default
* data directory, this is read. Otherwise, the
* default configuration is returned.
*/
def fromDefaultDatadir: BitcoindConfig = {
if (DEFAULT_CONF_FILE.isFile()) {
apply(DEFAULT_CONF_FILE)
} else {
BitcoindConfig.empty
}
}
/**
* @see https://en.bitcoin.it/wiki/Data_directory
*/
val DEFAULT_DATADIR: File = {
val path = if (Properties.isMac) {
Paths.get(Properties.userHome,
"Library",
"Application Support",
"Bitcoin")
} else {
Paths.get(Properties.userHome, ".bitcoin")
}
path.toFile
}
/** Default location of bitcoind conf file */
val DEFAULT_CONF_FILE: File = DEFAULT_DATADIR
.toPath()
.resolve("bitcoin.conf")
.toFile
}

View file

@ -4,20 +4,18 @@ import java.io.File
import java.net.URI
import java.nio.file.Paths
import com.typesafe.config._
import org.bitcoins.core.config.{NetworkParameters, _}
import org.bitcoins.rpc.client.common.BitcoindVersion
import scala.sys.process._
import scala.util.{Failure, Properties, Success, Try}
import org.bitcoins.core.util.BitcoinSLogger
import org.bitcoins.core.config.NetworkParameters
import scala.util.Try
import java.nio.file.Files
/**
* Created by chris on 4/29/17.
*/
sealed trait BitcoindInstance {
require(
rpcUri.getPort == rpcPort,
s"RpcUri and the rpcPort in authCredentials are different $rpcUri authcred: $rpcPort")
sealed trait BitcoindInstance extends BitcoinSLogger {
require(binary.exists,
s"bitcoind binary path (${binary.getAbsolutePath}) does not exist!")
@ -29,14 +27,14 @@ sealed trait BitcoindInstance {
/** The binary file that should get executed to start Bitcoin Core */
def binary: File
def datadir: File
def network: NetworkParameters
def uri: URI
def rpcUri: URI
def authCredentials: BitcoindAuthCredentials
def zmqConfig: ZmqConfig
def rpcPort: Int = authCredentials.rpcPort
def getVersion: BitcoindVersion = {
val binaryPath = binary.getAbsolutePath
@ -61,7 +59,8 @@ object BitcoindInstance {
rpcUri: URI,
authCredentials: BitcoindAuthCredentials,
zmqConfig: ZmqConfig,
binary: File
binary: File,
datadir: File
) extends BitcoindInstance
def apply(
@ -70,14 +69,16 @@ object BitcoindInstance {
rpcUri: URI,
authCredentials: BitcoindAuthCredentials,
zmqConfig: ZmqConfig = ZmqConfig(),
binary: File = DEFAULT_BITCOIND_LOCATION
binary: File = DEFAULT_BITCOIND_LOCATION,
datadir: File = BitcoindConfig.DEFAULT_DATADIR
): BitcoindInstance = {
BitcoindInstanceImpl(network,
uri,
rpcUri,
authCredentials,
zmqConfig = zmqConfig,
binary = binary)
binary = binary,
datadir = datadir)
}
lazy val DEFAULT_BITCOIND_LOCATION: File = {
@ -87,138 +88,70 @@ object BitcoindInstance {
new File(path.trim)
}
/**
* Taken from Bitcoin Wiki
* https://en.bitcoin.it/wiki/Data_directory
/** Constructs a `bitcoind` instance from the given datadir, using the
* `bitcoin.conf` found within (if any)
*
* @throws IllegalArgumentException if the given datadir does not exist
*/
private val DEFAULT_DATADIR =
if (Properties.isMac) {
Paths.get(Properties.userHome,
"Library",
"Application Support",
"Bitcoin")
} else {
Paths.get(Properties.userHome, ".bitcoin")
}
private val DEFAULT_CONF_FILE = DEFAULT_DATADIR.resolve("bitcoin.conf")
def fromDatadir(datadir: File = DEFAULT_DATADIR.toFile): BitcoindInstance = {
def fromDatadir(
datadir: File = BitcoindConfig.DEFAULT_DATADIR): BitcoindInstance = {
require(datadir.exists, s"${datadir.getPath} does not exist!")
require(datadir.isDirectory, s"${datadir.getPath} is not a directory!")
val file = Paths.get(datadir.getAbsolutePath, "bitcoin.conf").toFile
fromConfigFile(file)
val configPath = Paths.get(datadir.getAbsolutePath, "bitcoin.conf")
if (Files.exists(configPath)) {
val file = configPath.toFile()
fromConfigFile(file)
} else {
fromConfig(
BitcoindConfig.empty.withOption("datadir", configPath.toString))
}
}
/**
* Construct a `bitcoind` from the given config file. If no `datadir` setting
* is found, the parent directory to the given file is used.
*
* @throws IllegalArgumentException if the given config file does not exist
*/
def fromConfigFile(
file: File = DEFAULT_CONF_FILE.toFile): BitcoindInstance = {
file: File = BitcoindConfig.DEFAULT_CONF_FILE): BitcoindInstance = {
require(file.exists, s"${file.getPath} does not exist!")
require(file.isFile, s"${file.getPath} is not a file!")
val config = ConfigFactory.parseFile(
file,
ConfigParseOptions.defaults
.setSyntax(ConfigSyntax.PROPERTIES)) // bitcoin.conf is not a proper .conf file, uses Java properties=like syntax
val conf = BitcoindConfig(file)
val configWithDatadir =
if (config.hasPath("datadir")) {
config
} else {
config.withValue("datadir",
ConfigValueFactory.fromAnyRef(file.getParent))
}
val confWithDatadir = if (conf.datadir.isEmpty) {
conf.withOption("datadir", file.getParent.toString)
} else {
conf
}
fromConfig(configWithDatadir)
}
def fromConfig(config: Config): BitcoindInstance = {
val datadirStr = Try(config.getString("datadir"))
.getOrElse(
throw new IllegalArgumentException(
"Provided config does not contain \"datadir\" setting!"))
val datadir = new File(datadirStr)
require(datadir.exists, s"Datadir $datadirStr does not exist!")
require(datadir.isDirectory, s"Datadir $datadirStr is not directory")
fromConfig(config, datadir)
fromConfig(confWithDatadir)
}
/** Constructs a `bitcoind` instance from the given config */
def fromConfig(
config: Config,
datadir: File
config: BitcoindConfig
): BitcoindInstance = {
val network = getNetwork(config)
val uri = getUri(config, network)
val rpcUri = getRpcUri(config, network)
val authCredentials = BitcoindAuthCredentials.fromConfig(config)
val username = config.getString("rpcuser")
val password = config.getString("rpcpassword")
val authCredentials =
BitcoindAuthCredentials(username = username,
password = password,
rpcPort = rpcUri.getPort,
datadir = datadir)
BitcoindInstance(network,
uri,
rpcUri,
authCredentials,
zmqConfig = ZmqConfig.fromConfig(config))
}
private def isSet(config: Config, path: String): Boolean = {
Try(config.getInt(path))
.map(_ == 1)
.getOrElse(false)
}
private def getNetwork(config: Config): BitcoinNetwork = {
val isTestnet = isSet(config, path = "testnet")
val isRegTest = isSet(config, path = "regtest")
(isRegTest, isTestnet) match {
case (true, true) =>
throw new IllegalArgumentException(
""""Cannot set both "regtest" and "testnet" options""")
case (true, false) => RegTest
case (false, true) => TestNet3
case (false, false) => MainNet
config.datadir match {
case None =>
BitcoindInstance(config.network,
config.uri,
config.rpcUri,
authCredentials,
zmqConfig = ZmqConfig.fromConfig(config))
case Some(datadir) =>
BitcoindInstance(config.network,
config.uri,
config.rpcUri,
authCredentials,
zmqConfig = ZmqConfig.fromConfig(config),
datadir = datadir)
}
}
private def getUri(config: Config, network: NetworkParameters): URI = {
val port = Try(config.getInt("port")).getOrElse(network.port)
val host = Try(config.getString("bind")).getOrElse("localhost")
val uriT = Try {
new URI(s"http://$host:$port")
}
uriT match {
case Success(uriSuccess) => uriSuccess
case Failure(exception) =>
throw new IllegalArgumentException(
s"Could not construct URI from host $host and port $port",
exception)
}
}
private def getRpcUri(config: Config, network: NetworkParameters): URI = {
val rpcPort = Try(config.getInt("rpcport")).getOrElse(network.rpcPort)
val rpcHost = Try(config.getString("rpcbind")).getOrElse("localhost")
val rpcUriT = Try {
new URI(s"http://$rpcHost:$rpcPort")
}
rpcUriT match {
case Success(uriSuccess) => uriSuccess
case Failure(exception) =>
throw new IllegalArgumentException(
s"Could not construct URI from host $rpcHost and port $rpcPort",
exception)
}
}
}

View file

@ -1,10 +1,7 @@
package org.bitcoins.rpc.config
import java.net.URI
import com.typesafe.config.Config
import scala.util.Try
sealed trait ZmqConfig {
def hashBlock: Option[URI]
def rawBlock: Option[URI]
@ -43,48 +40,10 @@ object ZmqConfig {
rawTx = Some(uri))
}
def fromConfig(config: Config): ZmqConfig =
ZmqConfig(hashBlock = hashBlockUri(config),
hashTx = hashTxUri(config),
rawBlock = rawBlockUri(config),
rawTx = rawTxUri(config))
private val RAW_BLOCK_KEY = "zmqpubrawblock"
private val RAW_TX_KEY = "zmqpubrawtx"
private val HASH_BLOCK_KEY = "zmqpubhashblock"
private val HASH_TX_KEY = "zmqpubhashtx"
private val ZMQ_CONFIG_KEYS =
List(RAW_TX_KEY, RAW_BLOCK_KEY, HASH_TX_KEY, HASH_BLOCK_KEY)
private def isValidZmqConfigKey(key: String): Boolean =
ZMQ_CONFIG_KEYS.contains(key)
private def getZmqUri(config: Config, path: String): Option[URI] = {
require(
isValidZmqConfigKey(path),
s"$path is not a valid ZMQ config key. Valid keys: ${ZMQ_CONFIG_KEYS.mkString(", ")}")
if (config.hasPath(path)) {
Try(config.getString(path))
.map(str => Some(new URI(str)))
.getOrElse(throw new IllegalArgumentException(
s"$path (${config.getString(path)}) in config is not a valid URI"))
} else {
None
}
}
private def rawBlockUri(config: Config): Option[URI] =
getZmqUri(config, RAW_BLOCK_KEY)
private def rawTxUri(config: Config): Option[URI] =
getZmqUri(config, RAW_TX_KEY)
private def hashBlockUri(config: Config): Option[URI] =
getZmqUri(config, HASH_BLOCK_KEY)
private def hashTxUri(config: Config): Option[URI] =
getZmqUri(config, HASH_TX_KEY)
def fromConfig(config: BitcoindConfig): ZmqConfig =
ZmqConfig(hashBlock = config.zmqpubhashblock,
hashTx = config.zmqpubhashtx,
rawBlock = config.zmqpubrawblock,
rawTx = config.zmqpubrawtx)
}

View file

@ -156,6 +156,14 @@ object Networks {
val p2pkhNetworkBytes: Seq[ByteVector] = knownNetworks.map(_.p2pkhNetworkByte)
val p2shNetworkBytes: Seq[ByteVector] = knownNetworks.map(_.p2shNetworkByte)
/** Uses the notation used in `bitcoin.conf` */
def fromString(string: String): Option[NetworkParameters] = string match {
case "mainnet" => Some(MainNet)
case "testnet" => Some(TestNet3)
case "regtest" => Some(RegTest)
case _: String => None
}
def bytesToNetwork: Map[ByteVector, NetworkParameters] = Map(
MainNet.p2shNetworkByte -> MainNet,
MainNet.p2pkhNetworkByte -> MainNet,

View file

@ -26,17 +26,20 @@ import org.slf4j.Logger
import scala.concurrent._
import scala.concurrent.duration.DurationInt
import org.bitcoins.testkit.rpc.BitcoindRpcTestUtil
import akka.stream.StreamTcpException
class EclairRpcClientTest extends AsyncFlatSpec with BeforeAndAfterAll {
implicit val system: ActorSystem = ActorSystem("EclairRpcClient")
implicit val system: ActorSystem =
ActorSystem("EclairRpcClient", BitcoindRpcTestUtil.AKKA_CONFIG)
implicit val m: ActorMaterializer = ActorMaterializer.create(system)
implicit val ec: ExecutionContext = m.executionContext
implicit val bitcoinNp: RegTest.type = EclairRpcTestUtil.network
val logger: Logger = BitcoinSLogger.logger
val bitcoindRpcClientF: Future[BitcoindRpcClient] = {
lazy val bitcoindRpcClientF: Future[BitcoindRpcClient] = {
val cliF = EclairRpcTestUtil.startedBitcoindRpcClient()
// make sure we have enough money open channels
//not async safe
@ -45,7 +48,7 @@ class EclairRpcClientTest extends AsyncFlatSpec with BeforeAndAfterAll {
blocksF.flatMap(_ => cliF)
}
val eclairNodesF: Future[EclairNodes4] = {
lazy val eclairNodesF: Future[EclairNodes4] = {
bitcoindRpcClientF.flatMap { bitcoindRpcClient =>
val nodesF = EclairRpcTestUtil.createNodeLink(bitcoindRpcClient)
@ -110,6 +113,28 @@ class EclairRpcClientTest extends AsyncFlatSpec with BeforeAndAfterAll {
behavior of "RpcClient"
it should "be able to start and shutdown a node" in {
for {
bitcoind <- EclairRpcTestUtil.startedBitcoindRpcClient()
eclair <- {
val server = EclairRpcTestUtil.eclairInstance(bitcoind)
val eclair = new EclairRpcClient(server)
eclair.start().map(_ => eclair)
}
_ <- eclair.getInfo
_ = EclairRpcTestUtil.shutdown(eclair)
_ <- BitcoindRpcTestUtil.stopServer(bitcoind)
_ <- eclair.getInfo
.map(_ => fail("Got info from a closed node!"))
.recover {
case _: StreamTcpException => ()
}
} yield succeed
}
it should "be able to open and close a channel" in {
val changeAddrF = bitcoindRpcClientF.flatMap(_.getNewAddress)
@ -202,7 +227,10 @@ class EclairRpcClientTest extends AsyncFlatSpec with BeforeAndAfterAll {
}
val badCredentialsF = goodCredentialsF.map { good =>
EclairAuthCredentials("bad_password", good.bitcoinAuthOpt, good.port)
EclairAuthCredentials("bad_password",
good.bitcoinAuthOpt,
rpcPort = good.rpcPort,
bitcoindRpcUri = good.bitcoindRpcUri)
}
val badInstanceF = badCredentialsF.flatMap { badCredentials =>
@ -714,7 +742,7 @@ class EclairRpcClientTest extends AsyncFlatSpec with BeforeAndAfterAll {
}
it must "receive gossip messages about channel updates for nodes we do not have a direct channel with" ignore {
it must "receive gossip messages about channel updates for nodes we do not have a direct channel with" ignore {
//make sure we see payments outside of our immediate peers
//this is important because these gossip messages contain
//information about channel fees, so we need to get updates

View file

@ -6,15 +6,16 @@ import org.bitcoins.testkit.eclair.rpc.EclairRpcTestUtil
import org.bitcoins.testkit.rpc.BitcoindRpcTestUtil
import org.scalatest.{AsyncFlatSpec, BeforeAndAfterAll}
import org.slf4j.LoggerFactory
import akka.stream.StreamTcpException
class EclairRpcTestUtilTest extends AsyncFlatSpec with BeforeAndAfterAll {
private val logger = LoggerFactory.getLogger(getClass)
private implicit val actorSystem: ActorSystem =
ActorSystem.create("EclairRpcTestUtilTest")
ActorSystem("EclairRpcTestUtilTest", BitcoindRpcTestUtil.AKKA_CONFIG)
private val bitcoindRpcF = {
private lazy val bitcoindRpcF = {
val cliF = EclairRpcTestUtil.startedBitcoindRpcClient()
val blocksF = cliF.flatMap(_.generate(200))
blocksF.flatMap(_ => cliF)
@ -30,6 +31,17 @@ class EclairRpcTestUtilTest extends AsyncFlatSpec with BeforeAndAfterAll {
behavior of "EclairRpcTestUtilTest"
it must "spawn a V16 bitcoind instance" in {
for {
bitcoind <- EclairRpcTestUtil.startedBitcoindRpcClient()
_ <- bitcoind.getNetworkInfo
_ <- BitcoindRpcTestUtil.stopServer(bitcoind)
_ <- bitcoind.getNetworkInfo
.map(_ => fail("got info from stopped bitcoind!"))
.recover { case _: StreamTcpException => }
} yield succeed
}
it must "spawn four nodes and create a channel link between them" in {
val nodes4F = bitcoindRpcF.flatMap { bitcoindRpc =>
val nodes = EclairRpcTestUtil.createNodeLink(bitcoindRpc)

View file

@ -137,7 +137,6 @@ class EclairRpcClient(val instance: EclairInstance)(
}
override def connect(uri: NodeUri): Future[String] = {
logger.info(s"Connecting to $uri")
eclairCall[String]("connect", List(JsString(uri.toString)))
}
@ -583,7 +582,7 @@ class EclairRpcClient(val instance: EclairInstance)(
val p = Process(
s"java -jar -Declair.datadir=${instance.authCredentials.datadir.get} $pathToEclairJar &")
val result = p.run()
logger.info(
logger.debug(
s"Starting eclair with datadir ${instance.authCredentials.datadir.get}")
process = Some(result)
@ -605,8 +604,10 @@ class EclairRpcClient(val instance: EclairInstance)(
val p = Promise[Boolean]()
getInfo.onComplete {
case Success(_) => p.success(true)
case Failure(_) => p.success(false)
case Success(_) =>
p.success(true)
case Failure(exc) =>
p.success(false)
}
p.future

View file

@ -5,6 +5,7 @@ import java.io.File
import com.typesafe.config.{Config, ConfigFactory}
import org.bitcoins.core.config.{MainNet, RegTest, TestNet3}
import org.bitcoins.rpc.config.BitcoindAuthCredentials
import java.net.URI
sealed trait EclairAuthCredentials {
@ -24,20 +25,19 @@ sealed trait EclairAuthCredentials {
}
/** `rpcport` field in our `bitcoin.conf` file */
def bitcoinRpcPort: Option[Int] = {
bitcoinAuthOpt.map(_.rpcPort)
}
def bitcoindRpcUri: URI
/** `eclair.api.password` field in our `eclair.conf` file */
def password: String
/** The port for eclair's rpc client */
def port: Int
def rpcPort: Int
def copyWithDatadir(datadir: File): EclairAuthCredentials = {
EclairAuthCredentials(password = password,
bitcoinAuthOpt = bitcoinAuthOpt,
port = port,
rpcPort = rpcPort,
bitcoindRpcUri = bitcoindRpcUri,
datadir = Some(datadir))
}
}
@ -53,23 +53,22 @@ object EclairAuthCredentials {
private case class AuthCredentialsImpl(
password: String,
bitcoinAuthOpt: Option[BitcoindAuthCredentials],
port: Int,
rpcPort: Int,
bitcoindRpcUri: URI,
datadir: Option[File])
extends EclairAuthCredentials
def apply(
password: String,
bitcoinAuthOpt: Option[BitcoindAuthCredentials],
port: Int): EclairAuthCredentials = {
EclairAuthCredentials(password, bitcoinAuthOpt, port, None)
}
def apply(
password: String,
bitcoinAuthOpt: Option[BitcoindAuthCredentials],
port: Int,
datadir: Option[File]): EclairAuthCredentials = {
AuthCredentialsImpl(password, bitcoinAuthOpt, port, datadir)
rpcPort: Int,
bitcoindRpcUri: URI,
datadir: Option[File] = None): EclairAuthCredentials = {
AuthCredentialsImpl(password,
bitcoinAuthOpt,
rpcPort,
bitcoindRpcUri,
datadir)
}
def fromDatadir(datadir: File): EclairAuthCredentials = {
@ -104,19 +103,24 @@ object EclairAuthCredentials {
"eclair.bitcoind.rpcport",
defaultBitcoindPort)
val bitcoindRpcHost =
ConfigUtil.getStringOrElse(config, "eclair.bitcoind.host", "localhost")
val bitcoindUri = new URI(s"http://$bitcoindRpcHost:$bitcoindRpcPort")
//does eclair not have a username field??
val password = config.getString("eclair.api.password")
val eclairRpcPort = ConfigUtil.getIntOrElse(config, "eclair.api.port", 8080)
val bitcoindAuth = {
BitcoindAuthCredentials(username = bitcoindUsername,
password = bitcoindPassword,
rpcPort = bitcoindRpcPort)
BitcoindAuthCredentials.PasswordBased(username = bitcoindUsername,
password = bitcoindPassword)
}
EclairAuthCredentials(password = password,
bitcoinAuthOpt = Some(bitcoindAuth),
port = eclairRpcPort,
rpcPort = eclairRpcPort,
bitcoindRpcUri = bitcoindUri,
datadir = datadir)
}

View file

@ -28,6 +28,7 @@ import org.bitcoins.testkit.rpc.{BitcoindRpcTestUtil, TestRpcUtil}
import scala.concurrent.duration.DurationInt
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
import org.bitcoins.rpc.config.BitcoindAuthCredentials
/**
* @define nodeLinkDoc
@ -90,15 +91,9 @@ trait EclairRpcTestUtil extends BitcoinSLogger {
port: Int = RpcUtil.randomPort,
rpcPort: Int = RpcUtil.randomPort,
zmqPort: Int = RpcUtil.randomPort): BitcoindInstance = {
val uri = new URI("http://localhost:" + port)
val rpcUri = new URI("http://localhost:" + rpcPort)
val auth = BitcoindRpcTestUtil.authCredentials(uri, rpcUri, zmqPort, false)
BitcoindInstance(network = network,
uri = uri,
rpcUri = rpcUri,
authCredentials = auth,
zmqConfig = ZmqConfig.fromPort(zmqPort))
BitcoindRpcTestUtil.instance(port = port,
rpcPort = rpcPort,
zmqPort = zmqPort)
}
//cribbed from https://github.com/Christewart/eclair/blob/bad02e2c0e8bd039336998d318a861736edfa0ad/eclair-core/src/test/scala/fr/acinq/eclair/integration/IntegrationSpec.scala#L140-L153
@ -113,9 +108,13 @@ trait EclairRpcTestUtil extends BitcoinSLogger {
"eclair.server.public-ips.1" -> "127.0.0.1",
"eclair.server.binding-ip" -> "0.0.0.0",
"eclair.server.port" -> port,
"eclair.bitcoind.rpcuser" -> bitcoindInstance.authCredentials.username,
"eclair.bitcoind.rpcpassword" -> bitcoindInstance.authCredentials.password,
"eclair.bitcoind.rpcport" -> bitcoindInstance.authCredentials.rpcPort,
"eclair.bitcoind.rpcuser" -> bitcoindInstance.authCredentials
.asInstanceOf[BitcoindAuthCredentials.PasswordBased]
.username,
"eclair.bitcoind.rpcpassword" -> bitcoindInstance.authCredentials
.asInstanceOf[BitcoindAuthCredentials.PasswordBased]
.password,
"eclair.bitcoind.rpcport" -> bitcoindInstance.rpcUri.getPort,
// newer versions of Eclair has removed this config setting, in favor of
// the below it. All three are included here for good measure
"eclair.bitcoind.zmq" -> bitcoindInstance.zmqConfig.rawTx.get.toString,
@ -553,15 +552,13 @@ trait EclairRpcTestUtil extends BitcoinSLogger {
def getBitcoindRpc(eclairRpcClient: EclairRpcClient)(
implicit system: ActorSystem): BitcoindRpcClient = {
val bitcoindRpc = {
val eclairAuth = eclairRpcClient.instance.authCredentials
val bitcoindRpcPort = eclairAuth.bitcoinRpcPort.get
val instance = eclairRpcClient.instance
val auth = instance.authCredentials
val bitcoindInstance = BitcoindInstance(
network = eclairRpcClient.instance.network,
network = instance.network,
uri = new URI("http://localhost:18333"),
rpcUri = new URI(s"http://localhost:${bitcoindRpcPort}"),
authCredentials =
eclairRpcClient.instance.authCredentials.bitcoinAuthOpt.get
rpcUri = auth.bitcoindRpcUri,
authCredentials = auth.bitcoinAuthOpt.get
)
new BitcoindRpcClient(bitcoindInstance)(system)
}
@ -574,6 +571,7 @@ trait EclairRpcTestUtil extends BitcoinSLogger {
import system.dispatcher
val bitcoindRpc = getBitcoindRpc(eclairRpcClient)
logger.debug(s"shutting down eclair")
eclairRpcClient.stop()
bitcoindRpc.stop().map(_ => ())

View file

@ -1,12 +1,10 @@
package org.bitcoins.testkit.rpc
import java.io.{File, PrintWriter}
import java.net.URI
import java.nio.file.Paths
import akka.actor.ActorSystem
import akka.stream.ActorMaterializer
import com.typesafe.config.{Config, ConfigFactory, ConfigValueFactory}
import org.bitcoins.core.config.RegTest
import org.bitcoins.core.crypto.{
DoubleSha256Digest,
@ -50,13 +48,17 @@ import scala.collection.mutable
import scala.concurrent._
import scala.concurrent.duration.{DurationInt, FiniteDuration}
import scala.util._
import org.bitcoins.rpc.config.BitcoindConfig
import java.nio.file.Files
import java.io.File
import com.typesafe.config.Config
import com.typesafe.config.ConfigFactory
import java.nio.file.Path
//noinspection AccessorLikeMethodIsEmptyParen
trait BitcoindRpcTestUtil extends BitcoinSLogger {
import BitcoindRpcTestUtil.DEFAULT_LONG_DURATION
import scala.collection.JavaConverters._
type RpcClientAccum =
mutable.Builder[BitcoindRpcClient, Vector[BitcoindRpcClient]]
@ -76,7 +78,7 @@ trait BitcoindRpcTestUtil extends BitcoinSLogger {
/**
* Standard config used for testing purposes
*/
def standardConfig: Config = {
def standardConfig: BitcoindConfig = {
def newUri: URI = new URI(s"http://localhost:${RpcUtil.randomPort}")
config(uri = newUri,
rpcUri = newUri,
@ -88,88 +90,78 @@ trait BitcoindRpcTestUtil extends BitcoinSLogger {
uri: URI,
rpcUri: URI,
zmqPort: Int,
pruneMode: Boolean): Config = {
pruneMode: Boolean): BitcoindConfig = {
val pass = randomDirName
val username = "random_user_name"
val values = Map[String, String](
"rpcuser" -> username,
"rpcpassword" -> pass,
"rpcport" -> rpcUri.getPort.toString,
"port" -> uri.getPort.toString,
"daemon" -> "1",
"server" -> "1",
"debug" -> "1",
"regtest" -> "1",
"walletbroadcast" -> "1",
"txindex" -> (if (pruneMode) "0" else "1"), // pruning and txindex are not compatible
"zmqpubhashtx" -> s"tcp://127.0.0.1:$zmqPort",
"zmqpubhashblock" -> s"tcp://127.0.0.1:$zmqPort",
"zmqpubrawtx" -> s"tcp://127.0.0.1:$zmqPort",
"zmqpubrawblock" -> s"tcp://127.0.0.1:$zmqPort",
"prune" -> (if (pruneMode) "1" else "0")
)
val javaMap = values.asJava
ConfigFactory.parseMap(javaMap)
val conf = s"""
|regtest=1
|daemon=1
|server=1
|
|rpcuser=$username
|rpcpassword=$pass
|rpcport=${rpcUri.getPort}
|port=${uri.getPort}
|debug=1
|walletbroadcast=1
|txindex=${if (pruneMode) 0 else 1 /* pruning and txindex are not compatible */}
|zmqpubhashtx=tcp://127.0.0.1:$zmqPort
|zmqpubhashblock=tcp://127.0.0.1:$zmqPort
|zmqpubrawtx=tcp://127.0.0.1:$zmqPort
|zmqpubrawblock=tcp://127.0.0.1:$zmqPort
|prune=${if (pruneMode) 1 else 0}
""".stripMargin
BitcoindConfig(conf)
}
/**
* Assumes the `config` object has a `datadir` string. Returns the written
* file.
* Writes the config to the data directory within it, it it doesn't
* exist. Returns the written file. Assumes the config has a datadir.
*/
def writeConfigToFile(config: Config): File = {
def writeConfigToFile(config: BitcoindConfig): Path = {
val confSet = config.entrySet.asScala
val confStr =
confSet
.map(entry => {
val key = entry.getKey
val value = entry.getValue.unwrapped
s"$key=$value"
})
.mkString("\n")
val confStr = config.lines.mkString("\n")
val datadir = new File(config.getString("datadir"))
datadir.mkdir()
val datadir = config.datadir
.getOrElse(
throw new IllegalArgumentException(
"Provided bitcoind config does not have datadir field!"))
.toPath
val confFile = new java.io.File(datadir.getAbsolutePath + "/bitcoin.conf")
confFile.createNewFile()
val confFile = datadir.resolve("bitcoin.conf")
val pw = new PrintWriter(confFile)
pw.write(confStr)
pw.close()
Files.createDirectories(datadir)
if (!Files.exists(confFile)) {
Files.write(confFile, confStr.getBytes)
}
confFile
}
/**
* Creates a datadir and places the username/password combo
* in the bitcoin.conf in the datadir
* Creates a `bitcoind` config within the system temp
* directory, writes the file and returns the written
* file
*/
def authCredentials(
def writtenConfig(
uri: URI,
rpcUri: URI,
zmqPort: Int,
pruneMode: Boolean): BitcoindAuthCredentials = {
pruneMode: Boolean
): Path = {
val conf = config(uri, rpcUri, zmqPort, pruneMode)
val configWithDatadir =
if (conf.hasPath("datadir")) {
if (conf.datadir.isDefined) {
conf
} else {
conf.withValue("datadir",
ConfigValueFactory.fromAnyRef("/tmp/" + randomDirName))
val tempDir = Paths.get(Properties.tmpDir, randomDirName)
conf.withOption("datadir", tempDir.toString)
}
val configFile = writeConfigToFile(configWithDatadir)
val username = configWithDatadir.getString("rpcuser")
val pass = configWithDatadir.getString("rpcpassword")
BitcoindAuthCredentials(username,
pass,
rpcUri.getPort,
configFile.getParentFile)
val written = writeConfigToFile(configWithDatadir)
logger.debug(s"Wrote conf to ${written}")
written
}
lazy val network: RegTest.type = RegTest
@ -202,6 +194,7 @@ trait BitcoindRpcTestUtil extends BitcoinSLogger {
case BitcoindVersion.Unknown => BitcoindInstance.DEFAULT_BITCOIND_LOCATION
}
/** Creates a `bitcoind` instance within the user temporary directory */
def instance(
port: Int = RpcUtil.randomPort,
rpcPort: Int = RpcUtil.randomPort,
@ -210,7 +203,9 @@ trait BitcoindRpcTestUtil extends BitcoinSLogger {
versionOpt: Option[BitcoindVersion] = None): BitcoindInstance = {
val uri = new URI("http://localhost:" + port)
val rpcUri = new URI("http://localhost:" + rpcPort)
val auth = authCredentials(uri, rpcUri, zmqPort, pruneMode)
val configFile = writtenConfig(uri, rpcUri, zmqPort, pruneMode)
val conf = BitcoindConfig(configFile)
val auth = BitcoindAuthCredentials.fromConfig(conf)
val binary = versionOpt match {
case Some(version) =>
getBinary(version)
@ -221,7 +216,8 @@ trait BitcoindRpcTestUtil extends BitcoinSLogger {
rpcUri = rpcUri,
authCredentials = auth,
zmqConfig = ZmqConfig.fromPort(zmqPort),
binary = binary)
binary = binary,
datadir = configFile.getParent.toFile())
instance
}
@ -266,8 +262,16 @@ trait BitcoindRpcTestUtil extends BitcoinSLogger {
val serverStops = servers.map { s =>
val stopF = s.stop()
deleteTmpDir(s.getDaemon.authCredentials.datadir)
stopF
deleteTmpDir(s.getDaemon.datadir)
stopF.onComplete {
case Failure(exception) =>
logger.error(s"Could not shut down sever: $exception")
case Success(_) =>
}
for {
stop <- stopF
_ <- RpcUtil.awaitConditionF(() => s.isStoppedF)
} yield stop
}
Future.sequence(serverStops).map(_ => ())
}
@ -280,7 +284,19 @@ trait BitcoindRpcTestUtil extends BitcoinSLogger {
stopServers(Vector(server))
}
/**
* Deletes the given temporary directory
*
* @throws IllegalArgumentException if the
* given directory isn't in the user
* temp dir location
*/
def deleteTmpDir(dir: File): Boolean = {
val isTemp = dir.getPath startsWith Properties.tmpDir
if (!isTemp) {
throw new IllegalArgumentException(
s"Directory $dir is not in the system temp dir location! You most likely didn't mean to delete this directory.")
}
if (!dir.isDirectory) {
dir.delete()
} else {
@ -806,7 +822,7 @@ trait BitcoindRpcTestUtil extends BitcoinSLogger {
implicit executionContext: ExecutionContext): Future[Unit] = {
val stopsF = List(client1, client2).map { client =>
client.stop().map { _ =>
deleteTmpDir(client.getDaemon.authCredentials.datadir)
deleteTmpDir(client.getDaemon.datadir)
}
}
Future.sequence(stopsF).map(_ => ())
@ -841,6 +857,10 @@ trait BitcoindRpcTestUtil extends BitcoinSLogger {
clientAccum: RpcClientAccum = Vector.newBuilder)(
implicit system: ActorSystem): Future[BitcoindRpcClient] = {
implicit val ec: ExecutionContextExecutor = system.dispatcher
assert(
instance.datadir.getPath().startsWith(Properties.tmpDir),
s"${instance.datadir} is not in user temp dir! This could lead to bad things happening.")
//start the bitcoind instance so eclair can properly use it
val rpc = new BitcoindRpcClient(instance)
val startedF = rpc.start()