Skip to content

Commit

Permalink
Working implementation of Zeroconf (#1)
Browse files Browse the repository at this point in the history
* Working implementation of Zeroconf

* Prepare 1st version

* Fix 2.12 compile issue
  • Loading branch information
RustedBones authored Jan 13, 2021
1 parent 49d1b89 commit fd49fb5
Show file tree
Hide file tree
Showing 8 changed files with 370 additions and 170 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ jobs:
run: sbt ++${{ matrix.scala }} scalafmtCheckAll headerCheckAll

- name: Build project
run: sbt ++${{ matrix.scala }} compile
run: 'sbt ++${{ matrix.scala }} test it:test'
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Changelog

## Unreleased

## v0.1.0 (2021-01-13)

Initial release
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ Zeroconf for scala (multicast DNS service discovery)

## Versions

Work in progress...
| Version | Release date | cats version | Scala versions |
| ------- | ------------ | ----------- | ------------------- |
| `0.1.0` | ??? | `2.2.0` | `2.13.4`, `2.12.12` |

## Getting scout

Expand All @@ -18,8 +20,6 @@ libraryDependencies += "fr.davit" %% "scout" % "<version>"

## Zeroconf

Scanning for services

```scala
import cats.effect.{ContextShift, IO, Timer}
import fr.davit.scout.Zeroconf
Expand All @@ -34,10 +34,28 @@ import scala.concurrent.duration._
implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global)
implicit val timer: Timer[IO] = IO.timer(ExecutionContext.global)

val services = Zeroconf
.scan[IO](Zeroconf.Service("googlecast", "tcp"))
// service definition
val service = Zeroconf.Service("ipp", "tcp")

// Scanning for service instances
val instances = Zeroconf
.scan[IO](service)
.interruptAfter(50.seconds)
.compile
.toList
.unsafeRunSync()


// instance definition
val instance = Zeroconf.Instance(
service = service,
name = "Ed’s Party Mix",
port = 1010,
target = "eds-musicbox",
information = Map("codec" -> "ogg"),
addresses = Seq(InetAddress.getByName("169.254.150.84")) // use local address when left empty
)

// Registering an instance
Zeroconf.register[IO](instance)
```
63 changes: 34 additions & 29 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -13,44 +13,49 @@ lazy val filterScalacOptions = { options: Seq[String] =>
ThisBuild / crossScalaVersions := Seq("2.13.4", "2.12.12")
ThisBuild / githubWorkflowBuild := Seq(
WorkflowStep.Sbt(name = Some("Check project"), commands = List("scalafmtCheckAll", "headerCheckAll")),
WorkflowStep.Sbt(name = Some("Build project"), commands = List("compile")) // TODO run tests
WorkflowStep.Sbt(name = Some("Build project"), commands = List("test", "it:test"))
)
ThisBuild / githubWorkflowTargetBranches := Seq("master")
ThisBuild / githubWorkflowPublishTargetBranches := Seq.empty

lazy val commonSettings = Seq(
organization := "fr.davit",
organizationName := "Michel Davit",
version := "0.1.0-SNAPSHOT",
crossScalaVersions := (ThisBuild / crossScalaVersions).value,
scalaVersion := crossScalaVersions.value.head,
scalacOptions ~= filterScalacOptions,
homepage := Some(url(s"https://github.com/$username/$repo")),
licenses += ("Apache-2.0", new URL("https://www.apache.org/licenses/LICENSE-2.0.txt")),
startYear := Some(2020),
scmInfo := Some(ScmInfo(url(s"https://github.com/$username/$repo"), s"[email protected]:$username/$repo.git")),
developers := List(
Developer(
id = s"$username",
name = "Michel Davit",
email = "[email protected]",
url = url(s"https://github.com/$username")
)
),
publishMavenStyle := true,
Test / publishArtifact := false,
publishTo := Some(if (isSnapshot.value) Opts.resolver.sonatypeSnapshots else Opts.resolver.sonatypeStaging),
credentials ++= (for {
username <- sys.env.get("SONATYPE_USERNAME")
password <- sys.env.get("SONATYPE_PASSWORD")
} yield Credentials("Sonatype Nexus Repository Manager", "oss.sonatype.org", username, password)).toSeq
)
lazy val commonSettings = Defaults.itSettings ++
headerSettings(Configurations.IntegrationTest) ++
Seq(
organization := "fr.davit",
organizationName := "Michel Davit",
version := "0.1.0-SNAPSHOT",
crossScalaVersions := (ThisBuild / crossScalaVersions).value,
scalaVersion := crossScalaVersions.value.head,
scalacOptions ~= filterScalacOptions,
homepage := Some(url(s"https://github.com/$username/$repo")),
licenses += ("Apache-2.0", new URL("https://www.apache.org/licenses/LICENSE-2.0.txt")),
startYear := Some(2020),
scmInfo := Some(ScmInfo(url(s"https://github.com/$username/$repo"), s"[email protected]:$username/$repo.git")),
developers := List(
Developer(
id = s"$username",
name = "Michel Davit",
email = "[email protected]",
url = url(s"https://github.com/$username")
)
),
publishMavenStyle := true,
Test / publishArtifact := false,
publishTo := Some(if (isSnapshot.value) Opts.resolver.sonatypeSnapshots else Opts.resolver.sonatypeStaging),
credentials ++= (for {
username <- sys.env.get("SONATYPE_USERNAME")
password <- sys.env.get("SONATYPE_PASSWORD")
} yield Credentials("Sonatype Nexus Repository Manager", "oss.sonatype.org", username, password)).toSeq,
testFrameworks += new TestFramework("munit.Framework")
)

lazy val `scout` = (project in file("."))
.configs(IntegrationTest)
.settings(commonSettings: _*)
.settings(
libraryDependencies ++= Seq(
Dependencies.ScalaCollectionCompat,
Dependencies.Taxonomy,
Dependencies.Test.ScalaTest
Dependencies.Test.MUnit
)
)
14 changes: 8 additions & 6 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ import sbt._
object Dependencies {

object Versions {
val Decline = "1.3.0"
val ScalaTest = "3.2.2"
val Taxonomy = "0.2.0"
val Decline = "1.3.0"
val MUnit = "0.7.20"
val ScalaCollectionCompat = "2.3.2"
val Taxonomy = "0.3.0"
}

val Decline = "com.monovore" %% "decline-effect" % Versions.Decline
val Taxonomy = "fr.davit" %% "taxonomy-fs2" % Versions.Taxonomy
val Decline = "com.monovore" %% "decline-effect" % Versions.Decline
val ScalaCollectionCompat = "org.scala-lang.modules" %% "scala-collection-compat" % Versions.ScalaCollectionCompat
val Taxonomy = "fr.davit" %% "taxonomy-fs2" % Versions.Taxonomy

object Test {
val ScalaTest = "org.scalatest" %% "scalatest" % Versions.ScalaTest % "test"
val MUnit = "org.scalameta" %% "munit" % Versions.MUnit % "it,test"
}

}
105 changes: 105 additions & 0 deletions src/it/scala/fr/davit/scout/ZeroconfItSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright 2020 Michel Davit
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.davit.scout

import cats.effect.{ContextShift, IO, Timer}
import fr.davit.taxonomy.fs2.Dns
import fr.davit.taxonomy.model.record.{
DnsAAAARecordData,
DnsARecordData,
DnsPTRRecordData,
DnsRecordClass,
DnsRecordType,
DnsSRVRecordData,
DnsTXTRecordData
}
import fr.davit.taxonomy.model.{DnsMessage, DnsPacket, DnsQuestion, DnsResponseCode, DnsType}
import fr.davit.taxonomy.scodec.DnsCodec
import fs2.Stream
import munit.FunSuite
import scodec.Codec

import java.net.{Inet4Address, Inet6Address, InetAddress, InetSocketAddress}
import scala.concurrent.ExecutionContext

class ZeroconfItSpec extends FunSuite {

implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global)
implicit val timer: Timer[IO] = IO.timer(ExecutionContext.global)
implicit val coder: Codec[DnsMessage] = DnsCodec.dnsMessage

val googleCastService = Zeroconf.Service("googlecast", "tcp")

val ipv4 = InetAddress.getByName("1.2.3.4").asInstanceOf[Inet4Address]
val ipv6 = InetAddress.getByName("[2001:db8::8a2e:370:7334]").asInstanceOf[Inet6Address]

val googleCastInstance = Zeroconf.Instance(
googleCastService,
"Scala FTW",
8009,
"my-awesome-hostname",
Map("key" -> "value"),
List(ipv4, ipv6)
)

test("discover services") {
val result = Stream
.resource(Zeroconf.localMulticastSocket[IO]())
.flatMap(Dns.listen[IO])
.concurrently(Zeroconf.scan[IO](googleCastService))
.map(_.message)
.head
.compile
.toList
.unsafeRunSync()

assert(result.nonEmpty)
assert(result.head.header.`type` == DnsType.Query)
assert(result.head.questions.head.name == "_googlecast._tcp.local")
assert(result.head.questions.head.`type` == DnsRecordType.PTR)
}

test("register a service instance") {
val question = DnsQuestion("_googlecast._tcp.local", DnsRecordType.PTR, false, DnsRecordClass.Internet)
val message = DnsMessage.query(id = 0, isRecursionDesired = false, questions = Seq(question))
val packet = DnsPacket(new InetSocketAddress(InetAddress.getByName("224.0.0.251"), 5353), message)

val result = Stream
.resource(Zeroconf.localMulticastSocket[IO]())
.flatMap(s => Stream.eval(Dns.resolve(s, packet)).drain ++ Dns.listen(s)) // on multicast, resolve will receive its owns message
.concurrently(Zeroconf.register[IO](googleCastInstance))
.map(_.message)
.head
.compile
.toList
.unsafeRunSync()

assert(result.nonEmpty)
assert(result.head.header.`type` == DnsType.Response)
assert(result.head.header.responseCode == DnsResponseCode.Success)
assert(result.head.answers.head.name == "_googlecast._tcp.local")
assert(result.head.answers.head.data == DnsPTRRecordData("Scala FTW._googlecast._tcp.local"))
assert(result.head.additionals(0).name == "Scala FTW._googlecast._tcp.local")
assert(result.head.additionals(0).data == DnsSRVRecordData(0, 0, 8009, "my-awesome-hostname"))
assert(result.head.additionals(1).name == "Scala FTW._googlecast._tcp.local")
assert(result.head.additionals(1).data == DnsTXTRecordData(List("key=value")))
assert(result.head.additionals(2).name == "my-awesome-hostname")
assert(result.head.additionals(2).data == DnsARecordData(ipv4))
assert(result.head.additionals(3).name == "my-awesome-hostname")
assert(result.head.additionals(3).data == DnsAAAARecordData(ipv6))
}
}
Loading

0 comments on commit fd49fb5

Please sign in to comment.