Skip to content

Commit

Permalink
Support DNS hostnames in node announcements (#2234)
Browse files Browse the repository at this point in the history
It is now possible to specify a DNS host name as one of your
`server.public-ips` addresses.

DNS host names will not be resolved until eclair attempts to
connect to the peer.

See lightning/bolts#911
  • Loading branch information
remyers committed Aug 16, 2022
1 parent 33e6fac commit bb6148e
Show file tree
Hide file tree
Showing 21 changed files with 215 additions and 28 deletions.
4 changes: 4 additions & 0 deletions docs/release-notes/eclair-vnext.md
Expand Up @@ -143,6 +143,10 @@ eclair.on-chain-fees.spend-anchor-without-htlcs = false
This is disabled by default, because there is still a risk of losing funds until bitcoin adds support for package relay.
If the mempool becomes congested and the feerate is too low, the commitment transaction may never reach miners' mempools because it's below the minimum relay feerate.

#### Public IP addresses can be DNS host names

You can now specify a DNS host name as one of your `server.public-ips` addresses (see PR [#911](https://github.com/lightning/bolts/pull/911)). Note: you can not specify more than one DNS host name.

## Verifying signatures

You will need `gpg` and our release signing key 7A73FE77DE2C4027. Note that you can get it:
Expand Down
1 change: 1 addition & 0 deletions eclair-core/src/main/resources/reference.conf
Expand Up @@ -332,6 +332,7 @@ eclair {
use-for-ipv6 = true
use-for-tor = true
use-for-watchdogs = true
use-for-dnshostnames = true
randomize-credentials = false // this allows tor stream isolation
}

Expand Down
19 changes: 18 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Expand Up @@ -31,11 +31,12 @@ import fr.acinq.eclair.io.MessageRelay.{NoRelay, RelayAll, RelayChannelsOnly, Re
import fr.acinq.eclair.io.PeerConnection
import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig
import fr.acinq.eclair.payment.relay.Relayer.{RelayFees, RelayParams}
import fr.acinq.eclair.router.Announcements.AddressException
import fr.acinq.eclair.router.Graph.{HeuristicsConstants, WeightRatios}
import fr.acinq.eclair.router.PathFindingExperimentConf
import fr.acinq.eclair.router.Router.{MultiPartParams, PathFindingConf, RouterConf, SearchBoundaries}
import fr.acinq.eclair.tor.Socks5ProxyParams
import fr.acinq.eclair.wire.protocol.{Color, EncodingType, NodeAddress}
import fr.acinq.eclair.wire.protocol._
import grizzled.slf4j.Logging
import scodec.bits.ByteVector

Expand Down Expand Up @@ -179,6 +180,7 @@ object NodeParams extends Logging {
useForIPv6 = config.getBoolean("socks5.use-for-ipv6"),
useForTor = config.getBoolean("socks5.use-for-tor"),
useForWatchdogs = config.getBoolean("socks5.use-for-watchdogs"),
useForDnsHostnames = config.getBoolean("socks5.use-for-dnshostnames"),
))
} else {
None
Expand Down Expand Up @@ -300,6 +302,19 @@ object NodeParams extends Logging {
require(features.hasFeature(Features.ChannelType), s"${Features.ChannelType.rfcName} must be enabled")
}

def validateAddresses(addresses: List[NodeAddress]): Unit = {
val addressesError = if (addresses.count(_.isInstanceOf[DnsHostname]) > 1) {
Some(AddressException(s"Invalid server.public-ip addresses: can not have more than one DNS host name."))
} else {
addresses.collectFirst {
case address if address.isInstanceOf[Tor2] => AddressException(s"invalid server.public-ip address `$address`: Tor v2 is deprecated.")
case address if address.port == 0 && !address.isInstanceOf[Tor3] => AddressException(s"invalid server.public-ip address `$address`: A non-Tor address can not use port 0.")
}
}

require(addressesError.isEmpty, addressesError.map(_.message))
}

val pluginMessageParams = pluginParams.collect { case p: CustomFeaturePlugin => p }
val features = Features.fromConfiguration(config.getConfig("features"))
validateFeatures(features)
Expand Down Expand Up @@ -332,6 +347,8 @@ object NodeParams extends Logging {
.toList
.map(ip => NodeAddress.fromParts(ip, config.getInt("server.port")).get) ++ publicTorAddress_opt

validateAddresses(addresses)

val feeTargets = FeeTargets(
fundingBlockTarget = config.getInt("on-chain-fees.target-blocks.funding"),
commitmentBlockTarget = config.getInt("on-chain-fees.target-blocks.commitment"),
Expand Down
3 changes: 2 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/io/Client.scala
Expand Up @@ -44,12 +44,13 @@ class Client(keyPair: KeyPair, socks5ProxyParams_opt: Option[Socks5ProxyParams],

def receive: Receive = {
case Symbol("connect") =>
// note that there is no resolution here, it's either plain ip addresses, or unresolved tor hostnames
// note that only DNS host names are resolved here; plain ip addresses and tor hostnames are not resolved
val remoteAddress = remoteNodeAddress match {
case addr: IPv4 => new InetSocketAddress(addr.ipv4, addr.port)
case addr: IPv6 => new InetSocketAddress(addr.ipv6, addr.port)
case addr: Tor2 => InetSocketAddress.createUnresolved(addr.host, addr.port)
case addr: Tor3 => InetSocketAddress.createUnresolved(addr.host, addr.port)
case addr: DnsHostname => new InetSocketAddress(addr.host, addr.port)
}
val (peerOrProxyAddress, proxyParams_opt) = socks5ProxyParams_opt.map(proxyParams => (proxyParams, Socks5ProxyParams.proxyAddress(remoteNodeAddress, proxyParams))) match {
case Some((proxyParams, Some(proxyAddress))) =>
Expand Down
Expand Up @@ -211,7 +211,7 @@ object ReconnectionTask {
}

def getPeerAddressFromDb(nodeParams: NodeParams, remoteNodeId: PublicKey): Option[NodeAddress] = {
val nodeAddresses = nodeParams.db.peers.getPeer(remoteNodeId).toSeq ++ nodeParams.db.network.getNode(remoteNodeId).toSeq.flatMap(_.addresses)
val nodeAddresses = nodeParams.db.peers.getPeer(remoteNodeId).toSeq ++ nodeParams.db.network.getNode(remoteNodeId).toList.flatMap(_.validAddresses)
selectNodeAddress(nodeParams, nodeAddresses)
}

Expand Down
Expand Up @@ -70,11 +70,13 @@ object Announcements {

def makeNodeAnnouncement(nodeSecret: PrivateKey, alias: String, color: Color, nodeAddresses: List[NodeAddress], features: Features[NodeFeature], timestamp: TimestampSecond = TimestampSecond.now()): NodeAnnouncement = {
require(alias.length <= 32)
// sort addresses by ascending address descriptor type; do not reorder addresses within the same descriptor type
val sortedAddresses = nodeAddresses.map {
case address@(_: IPv4) => (1, address)
case address@(_: IPv6) => (2, address)
case address@(_: Tor2) => (3, address)
case address@(_: Tor3) => (4, address)
case address@(_: DnsHostname) => (5, address)
}.sortBy(_._1).map(_._2)
val witness = nodeAnnouncementWitnessEncode(timestamp, nodeSecret.publicKey, color, alias, features.unscoped(), sortedAddresses, TlvStream.empty)
val sig = Crypto.sign(witness, nodeSecret)
Expand All @@ -89,6 +91,8 @@ object Announcements {
)
}

case class AddressException(message: String) extends IllegalArgumentException(message)

/**
* BOLT 7:
* The creating node MUST set node-id-1 and node-id-2 to the public keys of the
Expand Down
Expand Up @@ -253,6 +253,12 @@ object Validation {
log.debug("received node announcement from {}", ctx.sender())
None
}
val rebroadcastNode = if (n.shouldRebroadcast) {
Some(n -> origins)
} else {
log.debug("will not rebroadcast {}", n)
None
}
if (d.stash.nodes.contains(n)) {
log.debug("ignoring {} (already stashed)", n)
val origins1 = d.stash.nodes(n) ++ origins
Expand All @@ -275,13 +281,13 @@ object Validation {
remoteOrigins.foreach(sendDecision(_, GossipDecision.Accepted(n)))
ctx.system.eventStream.publish(NodeUpdated(n))
db.updateNode(n)
d.copy(nodes = d.nodes + (n.nodeId -> n), rebroadcast = d.rebroadcast.copy(nodes = d.rebroadcast.nodes + (n -> origins)))
d.copy(nodes = d.nodes + (n.nodeId -> n), rebroadcast = d.rebroadcast.copy(nodes = d.rebroadcast.nodes ++ rebroadcastNode))
} else if (d.channels.values.exists(c => isRelatedTo(c.ann, n.nodeId))) {
log.debug("added node nodeId={}", n.nodeId)
remoteOrigins.foreach(sendDecision(_, GossipDecision.Accepted(n)))
ctx.system.eventStream.publish(NodesDiscovered(n :: Nil))
db.addNode(n)
d.copy(nodes = d.nodes + (n.nodeId -> n), rebroadcast = d.rebroadcast.copy(nodes = d.rebroadcast.nodes + (n -> origins)))
d.copy(nodes = d.nodes + (n.nodeId -> n), rebroadcast = d.rebroadcast.copy(nodes = d.rebroadcast.nodes ++ rebroadcastNode))
} else if (d.awaiting.keys.exists(c => isRelatedTo(c, n.nodeId))) {
log.debug("stashing {}", n)
d.copy(stash = d.stash.copy(nodes = d.stash.nodes + (n -> origins)))
Expand Down
Expand Up @@ -217,7 +217,7 @@ object Socks5Connection {
def portToByteString(port: Int): ByteString = ByteString((port & 0x0000ff00) >> 8, port & 0x000000ff)
}

case class Socks5ProxyParams(address: InetSocketAddress, credentials_opt: Option[Credentials], randomizeCredentials: Boolean, useForIPv4: Boolean, useForIPv6: Boolean, useForTor: Boolean, useForWatchdogs: Boolean)
case class Socks5ProxyParams(address: InetSocketAddress, credentials_opt: Option[Credentials], randomizeCredentials: Boolean, useForIPv4: Boolean, useForIPv6: Boolean, useForTor: Boolean, useForWatchdogs: Boolean, useForDnsHostnames: Boolean)

object Socks5ProxyParams {

Expand All @@ -237,6 +237,7 @@ object Socks5ProxyParams {
case _: IPv6 if proxyParams.useForIPv6 => Some(proxyParams.address)
case _: Tor2 if proxyParams.useForTor => Some(proxyParams.address)
case _: Tor3 if proxyParams.useForTor => Some(proxyParams.address)
case _: DnsHostname if proxyParams.useForDnsHostnames => Some(proxyParams.address)
case _ => None
}

Expand Down
Expand Up @@ -124,6 +124,7 @@ object CommonCodecs {
.typecase(2, (ipv6address :: uint16).as[IPv6])
.typecase(3, (base32(10) :: uint16).as[Tor2])
.typecase(4, (base32(35) :: uint16).as[Tor3])
.typecase(5, (variableSizeBytes(uint8, ascii) :: uint16).as[DnsHostname])

// this one is a bit different from most other codecs: the first 'len' element is *not* the number of items
// in the list but rather the number of bytes of the encoded list. The rationale is once we've read this
Expand Down
Expand Up @@ -351,16 +351,20 @@ object NodeAddress {
/**
* Creates a NodeAddress from a host and port.
*
* Note that non-onion hosts will be resolved.
* Note that only IP v4 and v6 hosts will be resolved, onion and DNS hosts names will not be resolved.
*
* We don't attempt to resolve onion addresses (it will be done by the tor proxy), so we just recognize them based on
* the .onion TLD and rely on their length to separate v2/v3.
*
* Host names that are not Tor, IPv4 or IPv6 are assumed to be a DNS name and are not immediately resolved.
*
*/
def fromParts(host: String, port: Int): Try[NodeAddress] = Try {
host match {
case _ if host.endsWith(".onion") && host.length == 22 => Tor2(host.dropRight(6), port)
case _ if host.endsWith(".onion") && host.length == 62 => Tor3(host.dropRight(6), port)
case _ => IPAddress(InetAddress.getByName(host), port)
case _ if InetAddresses.isInetAddress(host.filterNot(Set('[', ']'))) => IPAddress(InetAddress.getByName(host), port)
case _ => DnsHostname(host, port)
}
}

Expand All @@ -387,6 +391,7 @@ case class IPv4(ipv4: Inet4Address, port: Int) extends IPAddress { override def
case class IPv6(ipv6: Inet6Address, port: Int) extends IPAddress { override def host: String = InetAddresses.toUriString(ipv6) }
case class Tor2(tor2: String, port: Int) extends OnionAddress { override def host: String = tor2 + ".onion" }
case class Tor3(tor3: String, port: Int) extends OnionAddress { override def host: String = tor3 + ".onion" }
case class DnsHostname(dnsHostname: String, port: Int) extends IPAddress {override def host: String = dnsHostname}
// @formatter:on

case class NodeAnnouncement(signature: ByteVector64,
Expand All @@ -396,7 +401,20 @@ case class NodeAnnouncement(signature: ByteVector64,
rgbColor: Color,
alias: String,
addresses: List[NodeAddress],
tlvStream: TlvStream[NodeAnnouncementTlv] = TlvStream.empty) extends RoutingMessage with AnnouncementMessage with HasTimestamp
tlvStream: TlvStream[NodeAnnouncementTlv] = TlvStream.empty) extends RoutingMessage with AnnouncementMessage with HasTimestamp {

val validAddresses: List[NodeAddress] = {
// if port is equal to 0, SHOULD ignore ipv6_addr OR ipv4_addr OR hostname; SHOULD ignore Tor v2 onion services.
val validAddresses = addresses.filter(address => address.port != 0 || address.isInstanceOf[Tor3]).filterNot(address => address.isInstanceOf[Tor2])
// if more than one type 5 address is announced, SHOULD ignore the additional data.
validAddresses.filter(!_.isInstanceOf[DnsHostname]) ++ validAddresses.find(_.isInstanceOf[DnsHostname])
}

val shouldRebroadcast: Boolean = {
// if more than one type 5 address is announced, MUST not forward the node_announcement.
addresses.count(address => address.isInstanceOf[DnsHostname]) <= 1
}
}

case class ChannelUpdate(signature: ByteVector64,
chainHash: ByteVector32,
Expand Down
24 changes: 24 additions & 0 deletions eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala
Expand Up @@ -343,4 +343,28 @@ class StartupSpec extends AnyFunSuite {
assert(nodeParamsAttempt2.isSuccess)
}

test("NodeParams should fail when server.public-ips addresses or server.port are invalid") {
case class TestCase(publicIps: Seq[String], port: String, error: Option[String] = None, errorIp: Option[String] = None)
val testCases = Seq[TestCase](
TestCase(Seq("0.0.0.0", "140.82.121.4", "2620:1ec:c11:0:0:0:0:200", "2620:1ec:c11:0:0:0:0:201", "iq7zhmhck54vcax2vlrdcavq2m32wao7ekh6jyeglmnuuvv3js57r4id.onion", "of7husrflx7sforh3fw6yqlpwstee3wg5imvvmkp4bz6rbjxtg5nljad.onion", "acinq.co"), "9735"),
TestCase(Seq("140.82.121.4", "2620:1ec:c11:0:0:0:0:200", "acinq.fr", "iq7zhmhck54vcax2vlrdcavq2m32wao7ekh6jyeglmnuuvv3js57r4id.onion"), "0", Some("port 0"), Some("140.82.121.4")),
TestCase(Seq("hsmithsxurybd7uh.onion", "iq7zhmhck54vcax2vlrdcavq2m32wao7ekh6jyeglmnuuvv3js57r4id.onion"), "9735", Some("Tor v2"), Some("hsmithsxurybd7uh.onion")),
TestCase(Seq("acinq.co", "acinq.fr"), "9735", Some("DNS host name")),
)
testCases.foreach(test => {
val serverConf = ConfigFactory.parseMap(Map(
s"server.public-ips" -> test.publicIps.asJava,
s"server.port" -> test.port,
).asJava).withFallback(defaultConf)
val attempt = Try(makeNodeParamsWithDefaults(serverConf))
if (test.error.isEmpty) {
assert(attempt.isSuccess)
} else {
assert(attempt.isFailure)
assert(attempt.failed.get.getMessage.contains(test.error.get))
assert(test.errorIp.isEmpty || attempt.failed.get.getMessage.contains(test.errorIp.get))
}
})
}

}
Expand Up @@ -107,7 +107,8 @@ class BlockchainWatchdogSpec extends ScalaTestWithActorTestKit(ConfigFactory.loa
useForIPv4 = true,
useForIPv6 = true,
useForTor = true,
useForWatchdogs = true)
useForWatchdogs = true,
useForDnsHostnames = true)

if (proxyAcceptsConnections(proxyParams)) {
val eventListener = TestProbe[DangerousBlocksSkew]()
Expand Down
Expand Up @@ -59,7 +59,8 @@ class NetworkDbSpec extends AnyFunSuite {
val node_1 = Announcements.makeNodeAnnouncement(randomKey(), "node-alice", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
val node_2 = Announcements.makeNodeAnnouncement(randomKey(), "node-bob", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features(VariableLengthOnion -> Optional))
val node_3 = Announcements.makeNodeAnnouncement(randomKey(), "node-charlie", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features(VariableLengthOnion -> Optional))
val node_4 = Announcements.makeNodeAnnouncement(randomKey(), "node-charlie", Color(100.toByte, 200.toByte, 300.toByte), Tor2("aaaqeayeaudaocaj", 42000) :: Nil, Features.empty)
val node_4 = Announcements.makeNodeAnnouncement(randomKey(), "node-eve", Color(100.toByte, 200.toByte, 300.toByte), Tor3("of7husrflx7sforh3fw6yqlpwstee3wg5imvvmkp4bz6rbjxtg5nljad", 42000) :: Nil, Features.empty)
val node_5 = Announcements.makeNodeAnnouncement(randomKey(), "node-frank", Color(100.toByte, 200.toByte, 300.toByte), DnsHostname("eclair.invalid", 42000) :: Nil, Features.empty)

assert(db.listNodes().toSet == Set.empty)
db.addNode(node_1)
Expand All @@ -69,12 +70,14 @@ class NetworkDbSpec extends AnyFunSuite {
db.addNode(node_2)
db.addNode(node_3)
db.addNode(node_4)
assert(db.listNodes().toSet == Set(node_1, node_2, node_3, node_4))
db.addNode(node_5)
assert(db.listNodes().toSet == Set(node_1, node_2, node_3, node_4, node_5))
db.removeNode(node_2.nodeId)
assert(db.listNodes().toSet == Set(node_1, node_3, node_4))
assert(db.listNodes().toSet == Set(node_1, node_3, node_4, node_5))
db.updateNode(node_1)

assert(node_4.addresses == List(Tor2("aaaqeayeaudaocaj", 42000)))
assert(node_4.addresses == List(Tor3("of7husrflx7sforh3fw6yqlpwstee3wg5imvvmkp4bz6rbjxtg5nljad", 42000)))
assert(node_5.addresses == List(DnsHostname("eclair.invalid", 42000)))
}
}

Expand Down
Expand Up @@ -37,8 +37,8 @@ class NodeURISpec extends AnyFunSuite {
val testCases = List(
TestCase(s"$PUBKEY@$IPV4_ENDURANCE:9737", IPV4_ENDURANCE, 9737),
TestCase(s"$PUBKEY@$IPV4_ENDURANCE", IPV4_ENDURANCE, 9735),
TestCase(s"$PUBKEY@$NAME_ENDURANCE:9737", "13.248.222.197", 9737),
TestCase(s"$PUBKEY@$NAME_ENDURANCE", "13.248.222.197", 9735),
TestCase(s"$PUBKEY@$NAME_ENDURANCE:9737", NAME_ENDURANCE, 9737),
TestCase(s"$PUBKEY@$NAME_ENDURANCE", NAME_ENDURANCE, 9735),
TestCase(s"$PUBKEY@$IPV6:9737", "[2405:204:66a9:536c:873f:dc4a:f055:a298]", 9737),
TestCase(s"$PUBKEY@$IPV6", "[2405:204:66a9:536c:873f:dc4a:f055:a298]", 9735),
)
Expand Down
16 changes: 16 additions & 0 deletions eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
Expand Up @@ -137,6 +137,22 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle
mockServer.close()
}

test("return connection failure for a peer with an invalid dns host name") { f =>
import f._

// this actor listens to connection requests and creates connections
system.actorOf(ClientSpawner.props(nodeParams.keyPair, nodeParams.socksProxy_opt, nodeParams.peerConnectionConf, TestProbe().ref, TestProbe().ref))

val invalidDnsHostname_opt = NodeAddress.fromParts("eclair.invalid", 9735).toOption
assert(invalidDnsHostname_opt.nonEmpty)
assert(invalidDnsHostname_opt.get == DnsHostname("eclair.invalid", 9735))

val probe = TestProbe()
probe.send(peer, Peer.Init(Set.empty))
probe.send(peer, Peer.Connect(remoteNodeId, invalidDnsHostname_opt, probe.ref, isPersistent = true))
probe.expectMsgType[PeerConnection.ConnectionResult.ConnectionFailed]
}

test("successfully reconnect to peer at startup when there are existing channels", Tag("auto_reconnect")) { f =>
import f._

Expand Down
Expand Up @@ -95,12 +95,14 @@ class JsonSerializersSpec extends AnyFunSuite with Matchers {
val ipv6LocalHost = IPAddress(InetAddress.getByAddress(Array(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)), 9735)
val tor2 = Tor2("aaaqeayeaudaocaj", 7777)
val tor3 = Tor3("aaaqeayeaudaocajbifqydiob4ibceqtcqkrmfyydenbwha5dypsaijc", 9999)
val dnsHostName = DnsHostname("acinq.co", 8888)

JsonSerializers.serialization.write(ipv4)(org.json4s.DefaultFormats + NodeAddressSerializer) shouldBe s""""10.0.0.1:8888""""
JsonSerializers.serialization.write(ipv6)(org.json4s.DefaultFormats + NodeAddressSerializer) shouldBe s""""[2405:204:66a9:536c:873f:dc4a:f055:a298]:9737""""
JsonSerializers.serialization.write(ipv6LocalHost)(org.json4s.DefaultFormats + NodeAddressSerializer) shouldBe s""""[::1]:9735""""
JsonSerializers.serialization.write(tor2)(org.json4s.DefaultFormats + NodeAddressSerializer) shouldBe s""""aaaqeayeaudaocaj.onion:7777""""
JsonSerializers.serialization.write(tor3)(org.json4s.DefaultFormats + NodeAddressSerializer) shouldBe s""""aaaqeayeaudaocajbifqydiob4ibceqtcqkrmfyydenbwha5dypsaijc.onion:9999""""
JsonSerializers.serialization.write(dnsHostName)(org.json4s.DefaultFormats + NodeAddressSerializer) shouldBe s""""acinq.co:8888""""
}

test("PeerInfo serialization") {
Expand Down

0 comments on commit bb6148e

Please sign in to comment.