Skip to content

Commit

Permalink
DNS over HTTPS support (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
rorp authored Apr 21, 2024
1 parent 1cc5a21 commit 1a775f8
Show file tree
Hide file tree
Showing 8 changed files with 304 additions and 33 deletions.
42 changes: 42 additions & 0 deletions .github/workflows/scala.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

name: Scala CI

on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]

permissions:
contents: read

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: 11
distribution: 'adopt'

- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-m2

- name: Build Ecalir
run: git clone https://github.com/ACINQ/eclair.git; cd eclair/; git checkout v0.10.0; mvn install -DskipTests=true

- name: Run tests
run: mvn test
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ This [Eclair](https://github.com/ACINQ/eclair) plugin allows to pay to human-rea
It should be treated as a POC, because the BOLT12 address specification is not yet finalized, and the BOLT12 interoperability
between Linting implementations is not at production level.

The plugin uses Cloudflare's DNS over HTTPS service hosted at 1.1.1.1

## How to build

First you need to build its dependencies
Expand Down Expand Up @@ -38,12 +40,12 @@ Simply add the JAR file name to the Eclair node command line:
<PATH_TO_YOUR_ECLAIR_INSTALLATION>/eclair-node.sh target/bolt12-address-0.10.0.jar
```

## Tor is not supported (yet)
## Tor support

That means that the plugin will leak your IP-address to your DNS-server even if your node is Tor-only.
If Socks5 support is enabled in the Eclair config, the plugin will use it to connect over Tor automatically, no additional configuration is required.

## API

`fetchoffer` `--bolt12Address=<bolt12-address>` will fetch the offer associated with the given BOLT12 address from DNS.
`fetchoffer` `--bolt12Address=<bolt12-address>` will fetch the offer associated with the given BOLT12 address from DNS

`paybolt12address` `--bolt12Address=<bolt12-address>` `--amountMsat=<amount-msats>` will pay the offer associated with the BOLT12 address
55 changes: 55 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<scala.version.short>2.13</scala.version.short>
<eclair.version>0.10.0</eclair.version>
<akka.version>2.6.20</akka.version>
<sttp.version>3.8.16</sttp.version>
</properties>

<build>
Expand Down Expand Up @@ -75,6 +76,28 @@
</execution>
</executions>
</plugin>
<!-- enable scalatest -->
<plugin>
<groupId>org.scalatest</groupId>
<artifactId>scalatest-maven-plugin</artifactId>
<version>2.0.0</version>
<configuration>
<parallel>false</parallel>
<stdout>I</stdout>
<systemProperties>
<buildDirectory>${project.build.directory}</buildDirectory>
</systemProperties>
<argLine>-Xmx1024m -Dfile.encoding=UTF-8</argLine>
</configuration>
<executions>
<execution>
<id>test</id>
<goals>
<goal>test</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

Expand All @@ -97,13 +120,45 @@
<version>${eclair.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.softwaremill.sttp.client3</groupId>
<artifactId>core_${scala.version.short}</artifactId>
<version>${sttp.version}</version>
</dependency>
<dependency>
<groupId>com.softwaremill.sttp.client3</groupId>
<artifactId>okhttp-backend_${scala.version.short}</artifactId>
<version>${sttp.version}</version>
<exclusions>
<exclusion>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.softwaremill.sttp.client3</groupId>
<artifactId>json4s_${scala.version.short}</artifactId>
<version>${sttp.version}</version>
</dependency>
<dependency>
<groupId>org.json4s</groupId>
<artifactId>json4s-jackson_${scala.version.short}</artifactId>
<version>4.0.6</version>
</dependency>
<dependency>
<groupId>org.minidns</groupId>
<artifactId>minidns-hla</artifactId>
<version>1.0.4</version>
</dependency>

<!-- TESTS -->
<dependency>
<groupId>org.scalatest</groupId>
<artifactId>scalatest_${scala.version.short}</artifactId>
<version>3.2.16</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-testkit_${scala.version.short}</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,31 @@ import fr.acinq.eclair.api.serde.FormParamExtractors._

object ApiHandlers {

def registerRoutes(paymentHandler: PaymentHandler, eclairDirectives: EclairDirectives): Route = {
def registerRoutes(paymentHandler: PaymentHandler, eclairDirectives: EclairDirectives, offerFetcher: OfferFetcher): Route = {
import eclairDirectives._
import fr.acinq.eclair.api.serde.JsonSupport.{formats, marshaller, serialization}
implicit val ec = paymentHandler.appKit.system.dispatcher


val payBolt12Address: Route = postRequest("paybolt12address") { implicit t =>
formFields("bolt12Address".as[String], amountMsatFormParam, "quantity".as[Long].?, "maxAttempts".as[Int].?, "maxFeeFlatSat".as[Satoshi].?, "maxFeePct".as[Double].?, "externalId".?, "pathFindingExperimentName".?, "connectDirectly".as[Boolean].?, "blocking".as[Boolean].?) {
case (address, amountMsat, quantity_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, externalId_opt, pathFindingExperimentName_opt, connectDirectly, blocking_opt) =>
val offer = Bolt12Address(address).fetchOffer().get
blocking_opt match {
case Some(true) => complete(paymentHandler.payOfferBlocking(offer, amountMsat, quantity_opt.getOrElse(1), externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly.getOrElse(false)))
case _ => complete(paymentHandler.payOffer(offer, amountMsat, quantity_opt.getOrElse(1), externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly.getOrElse(false)))
}
complete(
for {
offer <- offerFetcher.fetchOffer(Bolt12Address(address))
res <- blocking_opt match {
case Some(true) => paymentHandler.payOfferBlocking(offer, amountMsat, quantity_opt.getOrElse(1), externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly.getOrElse(false))
case _ => paymentHandler.payOffer(offer, amountMsat, quantity_opt.getOrElse(1), externalId_opt, maxAttempts_opt, maxFeeFlat_opt, maxFeePct_opt, pathFindingExperimentName_opt, connectDirectly.getOrElse(false))
}
} yield res
)
}
}


val fetchOffer: Route = postRequest("fetchoffer") { implicit t =>
formFields("bolt12Address".as[String]) { (address: String) =>
val offer = Bolt12Address(address).fetchOffer().get
complete(offer)
complete(offerFetcher.fetchOffer(Bolt12Address(address)))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,12 @@ import org.minidns.record.TXT
import scala.util.Try

case class Bolt12Address(address: String) {
import io.github.rorp.bolt12addressplugin.Bolt12Address.Prefix

def toDomainName: Try[String] = Try {
address.split("@") match {
case Array(user, host) => s"$user.user._bitcoin-payment.$host"
case _ => throw new IllegalArgumentException("invalid BOLT12 address")
}
}

def fetchOffer(): Try[Offer] = {
for {
domain <- toDomainName
dnsResponse <- Try(DnssecResolverApi.INSTANCE.resolve(domain, classOf[TXT]))
offerStr <- extractOfferString(dnsResponse)
offer <- Offer.decode(offerStr)
} yield offer
}

private def extractOfferString(resolverResult: ResolverResult[TXT]): Try[String] = Try {
val answers = resolverResult.getAnswers
if (answers.isEmpty) throw new RuntimeException("DNS response was empty")
if (answers.size > 1) throw new RuntimeException("too many DNS records")
val txt = answers.iterator().next().getText
if (!txt.startsWith(Prefix)) throw new RuntimeException(s"invalid DNS data: `$txt`")
txt.substring(Prefix.length)
}
}

object Bolt12Address {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,22 @@ import grizzled.slf4j.Logging
class Bolt12AddressPlugin extends Plugin with RouteProvider with Logging {

private var paymentHandler: PaymentHandler = _
private var offerFetcher: OfferFetcher = _

override def params: PluginParams = new PluginParams {
override def name: String = "Bolt12AddressPlugin"
}

override def onSetup(setup: Setup): Unit = ()
override def onSetup(setup: Setup): Unit = {
}

override def onKit(kit: Kit): Unit = {
import kit.system.dispatcher
offerFetcher = new DnsOverHttps(kit.nodeParams.socksProxy_opt)
paymentHandler = PaymentHandler(kit)
}

override def route(eclairDirectives: EclairDirectives): Route = ApiHandlers.registerRoutes(paymentHandler, eclairDirectives)
override def route(eclairDirectives: EclairDirectives): Route = {
ApiHandlers.registerRoutes(paymentHandler, eclairDirectives, offerFetcher)
}
}
129 changes: 129 additions & 0 deletions src/main/scala/io/github/rorp/bolt12addressplugin/OfferFetcher.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package io.github.rorp.bolt12addressplugin

import fr.acinq.eclair.randomBytes
import fr.acinq.eclair.tor.Socks5ProxyParams
import fr.acinq.eclair.wire.protocol.OfferTypes.Offer
import org.minidns.hla.{DnssecResolverApi, ResolverResult}
import org.minidns.record.TXT
import sttp.client3.okhttp.OkHttpFutureBackend
import sttp.client3.{SttpBackend, SttpBackendOptions, UriContext, basicRequest}
import sttp.model.{HeaderNames, Uri}

import java.net.InetSocketAddress
import scala.concurrent.duration.{Duration, DurationInt, FiniteDuration}
import scala.concurrent.{Await, ExecutionContext, Future}
import scala.util.Try

trait OfferFetcher {
def fetchOffer(bolt12Address: Bolt12Address): Future[Offer]
}

object OfferFetcher {
def create(kind: String, socksProxy_opt: Option[Socks5ProxyParams])(implicit ec: ExecutionContext): Try[OfferFetcher] = Try {
kind.toLowerCase() match {
case "dns" => new Dns
case "doh" => new DnsOverHttps(socksProxy_opt)
}

}
}

class Dns extends OfferFetcher {
override def fetchOffer(bolt12Address: Bolt12Address): Future[Offer] = {
Future.fromTry(
for {
domainName <- bolt12Address.toDomainName
dnsResponse <- Try(DnssecResolverApi.INSTANCE.resolve(domainName, classOf[TXT]))
offerStr <- extractOfferString(dnsResponse)
offer <- Offer.decode(offerStr)
} yield offer)
}

private def extractOfferString(resolverResult: ResolverResult[TXT]): Try[String] = Try {
val answers = resolverResult.getAnswers
if (answers.isEmpty) throw new RuntimeException("DNS response was empty")
if (answers.size > 1) throw new RuntimeException("too many DNS records")
val txt = answers.iterator().next().getText
if (!txt.startsWith(Bolt12Address.Prefix)) throw new RuntimeException(s"invalid DNS data: `$txt`")
txt.substring(Bolt12Address.Prefix.length)
}
}

class DnsOverHttps(socksProxy_opt: Option[Socks5ProxyParams])(implicit ec: ExecutionContext) extends OfferFetcher {


val BaseUri: Uri = uri"https://1.1.1.1/dns-query"
val ReadTimeout: FiniteDuration = 10.seconds

private val sttp = createSttpBackend(socksProxy_opt)


private def extractOfferString(body: String): Try[String] = Try {
import io.github.rorp.bolt12addressplugin.DnsOverHttps.DnsResponse
val serialization = org.json4s.jackson.Serialization
implicit val formats = org.json4s.DefaultFormats
val json = serialization.read[DnsResponse](body)
val txt = {
val data = json.Answer.headOption.getOrElse(throw new RuntimeException(s"invalid DNS response: $json")).data
val data1 = if (data.startsWith("\"")) data.tail else data
if (data1.endsWith("\"")) data1.init else data1
}
if (!txt.startsWith(Bolt12Address.Prefix)) throw new RuntimeException(s"invalid DNS data: `$txt`")
txt.substring(Bolt12Address.Prefix.length)
}

override def fetchOffer(bolt12Address: Bolt12Address): Future[Offer] = {
val parametrizedUri = BaseUri.addParam("name", bolt12Address.toDomainName.get).addParam("type", "TXT")
val request = basicRequest
.header(HeaderNames.Accept, "application/dns-json", replaceExisting = true)
.readTimeout(ReadTimeout).get(parametrizedUri)
for {
response <- sttp.send(request)
} yield {
if (!response.code.isSuccess) throw new RuntimeException(s"Error performing DNS query: status code ${response.code}")
val body = response.body.getOrElse(throw new RuntimeException(s"Error performing DNS query: invalid body ${response.body}"))
val offer = extractOfferString(body).flatMap(Offer.decode)
offer.get
}
}

private def createSttpBackend(socksProxy_opt: Option[Socks5ProxyParams]): SttpBackend[Future, _] = {
val options = SttpBackendOptions(connectionTimeout = 30.seconds, proxy = None)
val sttpBackendOptions: SttpBackendOptions = socksProxy_opt match {
case Some(proxy) =>
val proxyOptions = options.connectionTimeout(120.seconds)
val host = proxy.address.getHostString
val port = proxy.address.getPort
if (proxy.randomizeCredentials)
proxyOptions.socksProxy(host, port, username = randomBytes(16).toHex, password = randomBytes(16).toHex)
else
proxyOptions.socksProxy(host, port)
case _ => options
}
OkHttpFutureBackend(sttpBackendOptions)
}

}

object DnsOverHttps {
case class DnsRecord(name: String, `type`: Int, TTL: Int, data: String)

case class DnsResponse(Answer: Seq[DnsRecord])
}

object Main {
def main(args: Array[String]): Unit = {
import scala.concurrent.ExecutionContext.Implicits.global
println(Await.result(new DnsOverHttps(Some(Socks5ProxyParams(
address = InetSocketAddress.createUnresolved("localhost", 9050),
credentials_opt = None,
randomizeCredentials = true,
useForIPv4 = true,
useForIPv6 = true,
useForTor = true,
useForWatchdogs = true,
useForDnsHostnames = true
))).fetchOffer(Bolt12Address("satoshi@twelve.cash")), Duration.Inf))
}

}
Loading

0 comments on commit 1a775f8

Please sign in to comment.