Skip to content

Commit

Permalink
Support DNS hostnames and deprecate Torv2 addresses in node announcem…
Browse files Browse the repository at this point in the history
…ents
  • Loading branch information
remyers committed Apr 12, 2022
1 parent 7883bf6 commit 2db6978
Show file tree
Hide file tree
Showing 17 changed files with 200 additions and 16 deletions.
6 changes: 6 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ Expired incoming invoices that are unpaid will be searched for and purged from t
* `eclair.purge-expired-invoices.enabled = true
* `eclair.purge-expired-invoices.interval = 24 hours`

#### Public IP addresses can be DNS host names, but not Tor v2 addresses

You can now specify a DNS host names 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.

Tor v2 addresses are no longer supported as a `server.public-ips` address and will be ignored in gossip messages (see PR [#940](https://github.com/lightning/bolts/pull/940]).

## Verifying signatures

You will need `gpg` and our release signing key 7A73FE77DE2C4027. Note that you can get it:
Expand Down
9 changes: 8 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ 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.Graph.{HeuristicsConstants, WeightRatios}
import fr.acinq.eclair.router.PathFindingExperimentConf
import fr.acinq.eclair.router.Router.{MultiPartParams, PathFindingConf, RouterConf, SearchBoundaries}
import fr.acinq.eclair.router.{Announcements, PathFindingExperimentConf}
import fr.acinq.eclair.tor.Socks5ProxyParams
import fr.acinq.eclair.wire.protocol.{Color, EncodingType, NodeAddress}
import grizzled.slf4j.Logging
Expand Down Expand Up @@ -297,6 +297,11 @@ object NodeParams extends Logging {
require(features.hasFeature(Features.ChannelType), s"${Features.ChannelType.rfcName} must be enabled")
}

def validateAddresses(addresses: List[NodeAddress]): Unit = {
val addressesError = Announcements.validateAddresses(addresses)
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 @@ -328,6 +333,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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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,17 @@ object Announcements {
)
}

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

def validateAddresses(addresses: List[NodeAddress]): Option[AddressException] = {
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.")
}
}

/**
* BOLT 7:
* The creating node MUST set node-id-1 and node-id-2 to the public keys of the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ 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 @@ -228,13 +232,12 @@ 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
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,11 @@ 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 => InetAddress.getByName(address.host) match {
case _: Inet4Address if proxyParams.useForIPv4 => Some(proxyParams.address)
case _: Inet6Address if proxyParams.useForIPv6 => Some(proxyParams.address)
case _ => None
}
case _ => None
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,15 @@ object CommonCodecs {

def base32(size: Int): Codec[String] = bytes(size).xmap(b => new Base32().encodeAsString(b.toArray).toLowerCase, a => ByteVector(new Base32().decode(a.toUpperCase())))

val punycode: Codec[String] = variableSizeBytes(uint8, ascii)

val nodeaddress: Codec[NodeAddress] =
discriminated[NodeAddress].by(uint8)
.typecase(1, (ipv4address :: uint16).as[IPv4])
.typecase(2, (ipv6address :: uint16).as[IPv6])
.typecase(3, (base32(10) :: uint16).as[Tor2])
.typecase(4, (base32(35) :: uint16).as[Tor3])
.typecase( 5, (punycode :: 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
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,22 @@ 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.
*
* We resolve host names comprised of only numbers and periods (IPv4) or that contain a colon (IPv6).
* Other host names are assumed to be a DNS name and are not immediately resolved.
*
*/
def fromParts(host: String, port: Int): Try[NodeAddress] = Try {
val ipv4v6 = "^([0-9.]*)?$|(:)".r
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 ipv4v6.findFirstIn(host).isDefined => IPAddress(InetAddress.getByName(host), port)
case _ => DnsHostname(host, port)
}
}

Expand All @@ -260,6 +266,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 @@ -269,7 +276,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 {

def 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.filter(_.isInstanceOf[DnsHostname]).take(1)
}

def 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
22 changes: 22 additions & 0 deletions eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -273,4 +273,26 @@ 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 && attempt.failed.get.getMessage.contains(test.error.get) &&
(test.errorIp.isEmpty || attempt.failed.get.getMessage.contains(test.errorIp.get)))
})
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ 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-charlie", Color(100.toByte, 200.toByte, 300.toByte), Tor3("of7husrflx7sforh3fw6yqlpwstee3wg5imvvmkp4bz6rbjxtg5nljad", 42000) :: Nil, Features.empty)

assert(db.listNodes().toSet === Set.empty)
db.addNode(node_1)
Expand All @@ -73,7 +73,7 @@ class NetworkDbSpec extends AnyFunSuite {
assert(db.listNodes().toSet === Set(node_1, node_3, node_4))
db.updateNode(node_1)

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

Expand Down
16 changes: 15 additions & 1 deletion eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,20 @@ 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

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 @@ -165,7 +179,7 @@ class PeerSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Paralle
connect(remoteNodeId, peer, peerConnection, switchboard, channels = Set(ChannelCodecsSpec.normal))

probe.send(peer, Peer.Connect(remoteNodeId, None, probe.ref, isPersistent = true))
probe.expectMsgType[PeerConnection.ConnectionResult.AlreadyConnected]
peerConnection.expectMsgType[PeerConnection.ConnectionResult.AlreadyConnected]
}

test("handle unknown messages") { f =>
Expand Down
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit 2db6978

Please sign in to comment.