Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support DNS hostnames in node announcements #2234

Merged
merged 16 commits into from
Aug 16, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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 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.

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]).
t-bast marked this conversation as resolved.
Show resolved Hide resolved

## 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] = {
t-bast marked this conversation as resolved.
Show resolved Hide resolved
t-bast marked this conversation as resolved.
Show resolved Hide resolved
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
}
t-bast marked this conversation as resolved.
Show resolved Hide resolved
if (d.stash.nodes.contains(n)) {
log.debug("ignoring {} (already stashed)", n)
val origins1 = d.stash.nodes(n) ++ origins
Expand All @@ -228,13 +232,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
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 {
t-bast marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -126,12 +126,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)
t-bast marked this conversation as resolved.
Show resolved Hide resolved

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 @@ -312,16 +312,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
t-bast marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -348,6 +354,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 @@ -357,7 +364,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])
t-bast marked this conversation as resolved.
Show resolved Hide resolved
// if more than one type 5 address is announced, SHOULD ignore the additional data.
validAddresses.filter(!_.isInstanceOf[DnsHostname]) ++ validAddresses.filter(_.isInstanceOf[DnsHostname]).take(1)
t-bast marked this conversation as resolved.
Show resolved Hide resolved
}

def shouldRebroadcast: Boolean = {
t-bast marked this conversation as resolved.
Show resolved Hide resolved
// 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") {
t-bast marked this conversation as resolved.
Show resolved Hide resolved
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)))
t-bast marked this conversation as resolved.
Show resolved Hide resolved
})
}

}
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)
t-bast marked this conversation as resolved.
Show resolved Hide resolved

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
Original file line number Diff line number Diff line change
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
14 changes: 14 additions & 0 deletions 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: the .invalid top level domain will never resolve.

t-bast marked this conversation as resolved.
Show resolved Hide resolved

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
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