From 89a7c8eaefd89f17dfff3fde0d2dbbdb0f688ed8 Mon Sep 17 00:00:00 2001 From: Hellings Date: Wed, 23 Sep 2026 23:39:11 -0400 Subject: [PATCH] Initial commit. --- .gitattributes | 12 + .gitignore | 12 + Justfile | 16 + README.md | 12 + app/build.gradle.kts | 32 + .../crit/commonip/app/ClusterGeolocation.kt | 93 + .../kotlin/rip/crit/commonip/app/MainKt.kt | 116 ++ build-logic/build.gradle.kts | 18 + build-logic/settings.gradle.kts | 14 + ....kotlin-application-conventions.gradle.kts | 11 + ...logic.kotlin-common-conventions.gradle.kts | 54 + .../kotlin/buildlogic.kotlin-fmt.gradle.kts | 17 + ...ogic.kotlin-library-conventions.gradle.kts | 11 + .../kotlin/buildlogic/GraalNativeImageTask.kt | 30 + gradle.properties | 7 + gradle/libs.versions.toml | 8 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 47505 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 248 +++ gradlew.bat | 82 + node.yaml.example | 7 + packages/build.gradle.kts | 1 + packages/decentraland/build.gradle.kts | 10 + packages/decentraland/chksum/build.gradle.kts | 1 + .../decentraland/chksum/ContentHash.kt | 10 + packages/decentraland/git/build.gradle.kts | 3 + .../commonip/decentraland/git/Provenance.kt | 24 + .../commonip/decentraland/git/Repository.kt | 67 + .../decentraland/git/RevisionGraph.kt | 99 ++ .../decentraland/git/SignedCommitLedger.kt | 72 + .../decentraland/git/RepositoryTest.kt | 32 + .../decentraland/kademlia/build.gradle.kts | 7 + .../decentraland/kademlia/KademliaNode.kt | 111 ++ .../decentraland/kademlia/NodeNamer.kt | 25 + .../decentraland/kademlia/NodeRegistry.kt | 58 + .../decentraland/kademlia/NodeNamerTest.kt | 14 + packages/decentraland/settings.gradle.kts | 3 + .../decentraland/SshDashboardService.kt | 33 + packages/ml/build.gradle.kts | 13 + .../rip/crit/commonip/ml/LocationEstimator.kt | 235 +++ .../crit/commonip/ml/LocationEstimatorTest.kt | 20 + packages/rawr/build.gradle.kts | 13 + .../rip/crit/commonip/rawr/feeds/RFC8805.kt | 9 + .../rip/crit/commonip/rawr/feeds/RFC9632.kt | 20 + .../rip/crit/commonip/rawr/feeds/RirFeed.kt | 37 + .../crit/commonip/rawr/providers/AFRINIC.kt | 8 + .../rip/crit/commonip/rawr/providers/APNIC.kt | 8 + .../rip/crit/commonip/rawr/providers/ARIN.kt | 8 + .../crit/commonip/rawr/providers/LACNIC.kt | 8 + .../crit/commonip/rawr/providers/RIPENCC.kt | 8 + .../rawr/providers/RirGeoFeedLoader.kt | 49 + .../commonip/rawr/providers/RirProvider.kt | 31 + .../rip/crit/commonip/rawr/types/Ingest.kt | 36 + .../rawr/providers/RirProviderTest.kt | 13 + packages/serialize/build.gradle.kts | 9 + .../crit/commonip/serialize/Deserialize.kt | 3 + .../rip/crit/commonip/serialize/GeoFeed.kt | 34 + .../crit/commonip/serialize/GeoFeedCodec.kt | 158 ++ .../rip/crit/commonip/serialize/Serialize.kt | 3 + .../serialize/cityStateCountry/CSV.kt | 1 + .../serialize/cityStateCountry/JSON.kt | 1 + .../serialize/cityStateCountry/XML.kt | 1 + .../crit/commonip/serialize/common/CSVSpec.kt | 1 + .../commonip/serialize/common/JSONSpec.kt | 1 + .../crit/commonip/serialize/common/Schema.kt | 1 + .../crit/commonip/serialize/common/XMLSpec.kt | 1 + .../crit/commonip/serialize/country/CSV.kt | 1 + .../crit/commonip/serialize/country/JSON.kt | 1 + .../crit/commonip/serialize/country/XML.kt | 1 + .../commonip/serialize/stateCountry/CSV.kt | 1 + .../commonip/serialize/stateCountry/JSON.kt | 1 + .../commonip/serialize/stateCountry/XML.kt | 1 + .../serialize/zipCityStateCountry/CSV.kt | 1 + .../serialize/zipCityStateCountry/JSON.kt | 1 + .../serialize/zipCityStateCountry/XML.kt | 1 + .../commonip/serialize/GeoFeedCodecTest.kt | 15 + packages/settings.gradle.kts | 3 + proprietary/build.gradle.kts | 74 + .../crit/commonip/proprietary/GeoProvider.kt | 19 + .../crit/commonip/proprietary/OpenGeoFeed.kt | 18 + .../rip/crit/commonip/proprietary/Rdap.kt | 6 + .../commonip/proprietary/RequestGovernor.kt | 31 + .../commonip/proprietary/StarlinkGeoFeed.kt | 13 + .../rip/crit/commonip/proprietary/Whois.kt | 17 + .../crit/commonip/proprietary/WhoisServers.kt | 1535 +++++++++++++++++ .../commonip/proprietary/LiveProviderTest.kt | 13 + .../crit/commonip/proprietary/ProviderTest.kt | 19 + scripts/commonip.service | 12 + scripts/deploy-ssh.sh | 14 + settings.gradle.kts | 13 + 90 files changed, 3920 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Justfile create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/kotlin/rip/crit/commonip/app/ClusterGeolocation.kt create mode 100644 app/src/main/kotlin/rip/crit/commonip/app/MainKt.kt create mode 100644 build-logic/build.gradle.kts create mode 100644 build-logic/settings.gradle.kts create mode 100644 build-logic/src/main/kotlin/buildlogic.kotlin-application-conventions.gradle.kts create mode 100644 build-logic/src/main/kotlin/buildlogic.kotlin-common-conventions.gradle.kts create mode 100644 build-logic/src/main/kotlin/buildlogic.kotlin-fmt.gradle.kts create mode 100644 build-logic/src/main/kotlin/buildlogic.kotlin-library-conventions.gradle.kts create mode 100644 build-logic/src/main/kotlin/buildlogic/GraalNativeImageTask.kt create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 node.yaml.example create mode 100644 packages/build.gradle.kts create mode 100644 packages/decentraland/build.gradle.kts create mode 100644 packages/decentraland/chksum/build.gradle.kts create mode 100644 packages/decentraland/chksum/src/main/kotlin/rip/crit/commonip/decentraland/chksum/ContentHash.kt create mode 100644 packages/decentraland/git/build.gradle.kts create mode 100644 packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/Provenance.kt create mode 100644 packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/Repository.kt create mode 100644 packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/RevisionGraph.kt create mode 100644 packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/SignedCommitLedger.kt create mode 100644 packages/decentraland/git/src/test/kotlin/rip/crit/commonip/decentraland/git/RepositoryTest.kt create mode 100644 packages/decentraland/kademlia/build.gradle.kts create mode 100644 packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/KademliaNode.kt create mode 100644 packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/NodeNamer.kt create mode 100644 packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/NodeRegistry.kt create mode 100644 packages/decentraland/kademlia/src/test/kotlin/rip/crit/commonip/decentraland/kademlia/NodeNamerTest.kt create mode 100644 packages/decentraland/settings.gradle.kts create mode 100644 packages/decentraland/src/main/kotlin/rip/crit/commonip/decentraland/SshDashboardService.kt create mode 100644 packages/ml/build.gradle.kts create mode 100644 packages/ml/src/main/kotlin/rip/crit/commonip/ml/LocationEstimator.kt create mode 100644 packages/ml/src/test/kotlin/rip/crit/commonip/ml/LocationEstimatorTest.kt create mode 100644 packages/rawr/build.gradle.kts create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RFC8805.kt create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RFC9632.kt create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RirFeed.kt create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/AFRINIC.kt create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/APNIC.kt create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/ARIN.kt create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/LACNIC.kt create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RIPENCC.kt create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RirGeoFeedLoader.kt create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RirProvider.kt create mode 100644 packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/types/Ingest.kt create mode 100644 packages/rawr/src/test/kotlin/rip/crit/commonip/rawr/providers/RirProviderTest.kt create mode 100644 packages/serialize/build.gradle.kts create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/Deserialize.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/GeoFeed.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/GeoFeedCodec.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/Serialize.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/CSV.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/JSON.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/XML.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/CSVSpec.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/JSONSpec.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/Schema.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/XMLSpec.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/CSV.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/JSON.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/XML.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/CSV.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/JSON.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/XML.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/CSV.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/JSON.kt create mode 100644 packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/XML.kt create mode 100644 packages/serialize/src/test/kotlin/rip/crit/commonip/serialize/GeoFeedCodecTest.kt create mode 100644 packages/settings.gradle.kts create mode 100644 proprietary/build.gradle.kts create mode 100644 proprietary/src/main/kotlin/rip/crit/commonip/proprietary/GeoProvider.kt create mode 100644 proprietary/src/main/kotlin/rip/crit/commonip/proprietary/OpenGeoFeed.kt create mode 100644 proprietary/src/main/kotlin/rip/crit/commonip/proprietary/Rdap.kt create mode 100644 proprietary/src/main/kotlin/rip/crit/commonip/proprietary/RequestGovernor.kt create mode 100644 proprietary/src/main/kotlin/rip/crit/commonip/proprietary/StarlinkGeoFeed.kt create mode 100644 proprietary/src/main/kotlin/rip/crit/commonip/proprietary/Whois.kt create mode 100644 proprietary/src/main/kotlin/rip/crit/commonip/proprietary/WhoisServers.kt create mode 100644 proprietary/src/test/kotlin/rip/crit/commonip/proprietary/LiveProviderTest.kt create mode 100644 proprietary/src/test/kotlin/rip/crit/commonip/proprietary/ProviderTest.kt create mode 100644 scripts/commonip.service create mode 100755 scripts/deploy-ssh.sh create mode 100644 settings.gradle.kts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..566ef1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build + +# Ignore Kotlin plugin data +.kotlin + +# Node credentials and deployment-specific endpoints +node.yaml +deploy/*.yaml diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..08628cf --- /dev/null +++ b/Justfile @@ -0,0 +1,16 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +build: + ./gradlew build + +fmt: + ./gradlew spotlessApply + +check: + ./gradlew spotlessCheck build + +native: + ./gradlew nativeImage + +deploy host user: + scripts/deploy-ssh.sh {{host}} {{user}} diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2e6ec5 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# CommonIP + +CommonIP, not to be confused with [CommonApp](https://commonapp.org), is a decentralized geolocation network that specifically avoids proprietary geolocation services whenever possible. It's written in the ever-so-portable Kotlin. Use it from here, use it from there, use it from anywhere! + +# donate + +Please donate to me to keep this project and its (centralized) [geofeeds](https://ai.crit.rip/automatedintelligence/geofeeds) alive. + +- `eth` 0xF30C281Aa2CD94618D902AE5bb9101E9F5385fE3 +- `sol` 99zJPASUz9PKLFaJWrxHCEtLZxeEePYpgZ74Ly9YNUHy +- `paypal` +1 (267) 806-2744 - Stephen Hellings +- `zelle` +1 (267) 806-2744 - STEPHEN HELLINGS diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..131beb3 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,32 @@ +import buildlogic.GraalNativeImageTask + +plugins { + id("buildlogic.kotlin-application-conventions") +} + +dependencies { + implementation("org.apache.commons:commons-text") + implementation(project(":packages:serialize")) + implementation(project(":packages:rawr")) + implementation(project(":proprietary")) + implementation(project(":packages:ml")) + implementation(project(":packages:decentraland")) +} + +application { + mainClass = "rip.crit.commonip.app.MainKtKt" +} + +tasks.named("run") { + standardInput = System.`in` +} + +val nativeImage = + tasks.register("nativeImage") { + group = "distribution" + description = "Builds a GraalVM native executable with the active Gradle toolchain." + dependsOn(tasks.named("classes")) + classpath.set(sourceSets.main.get().runtimeClasspath.files.map { it.absolutePath }) + mainClass.set("rip.crit.commonip.app.MainKtKt") + output.set(layout.buildDirectory.file("native/commonip")) + } diff --git a/app/src/main/kotlin/rip/crit/commonip/app/ClusterGeolocation.kt b/app/src/main/kotlin/rip/crit/commonip/app/ClusterGeolocation.kt new file mode 100644 index 0000000..8925c27 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/commonip/app/ClusterGeolocation.kt @@ -0,0 +1,93 @@ +package rip.crit.commonip.app + +import java.net.InetSocketAddress +import java.util.concurrent.ConcurrentHashMap +import rip.crit.commonip.decentraland.git.RepositoryTrust +import rip.crit.commonip.decentraland.kademlia.KademliaNode +import rip.crit.commonip.decentraland.kademlia.NodeRegistry +import rip.crit.commonip.ml.GeoLocation as MlGeoLocation +import rip.crit.commonip.ml.LocationEstimator +import rip.crit.commonip.proprietary.OpenGeoFeed +import rip.crit.commonip.proprietary.StarlinkGeoFeed +import rip.crit.commonip.rawr.providers.RirGeoFeedLoader +import rip.crit.commonip.rawr.providers.RirMetadataField +import rip.crit.commonip.rawr.types.Cidr +import rip.crit.commonip.serialize.GeoLocation + +class ClusterGeolocation(private val registry: NodeRegistry, bind: InetSocketAddress) : + AutoCloseable { + private val feeds = ConcurrentHashMap() + private val local = ConcurrentHashMap() + private val dht = KademliaNode(bind, registry) + + fun start(seeds: List = emptyList()) { + dht.start() + seeds.forEach(dht::bootstrap) + } + + fun addFeed(url: String, trust: RepositoryTrust = RepositoryTrust.COMMUNITY) { + feeds[url] = trust + } + + fun addStarlinkFeed() { + feeds[StarlinkGeoFeed.URL] = RepositoryTrust.TAINTED + } + + fun pullRirRegistryObject( + source: String, + registryObject: String, + fields: Set, + trust: RepositoryTrust = RepositoryTrust.COMMUNITY, + ) { + RirGeoFeedLoader(source, fields).pull(registryObject).forEach { feed -> + feed.records.forEach { record -> + local[record.prefix] = record + dht.store( + "${if (trust == RepositoryTrust.TAINTED) "commonip/tainted-full" else "commonip/community"}:${record.prefix}", + record.prefix, + ) + } + registry.publish( + "local", + "rir geofeed pulled", + "$source yielded ${feed.records.size} records", + ) + } + } + + fun pullAll() { + feeds.forEach { (url, trust) -> + runCatching { OpenGeoFeed().pull(url).records } + .onSuccess { records -> + records.forEach { record -> + local[record.prefix] = record + dht.store( + "${if (trust == RepositoryTrust.TAINTED) "commonip/tainted-full" else "commonip/community"}:${record.prefix}", + record.prefix, + ) + } + registry.publish( + "local", + "${trust.name.lowercase()} feed pulled", + "$url yielded ${records.size} records", + ) + } + .onFailure { registry.publish("local", "feed pull failed", "$url ${it.message}") } + } + } + + fun lookup(address: String): GeoLocation? = + local.values + .filter { Cidr.contains(it.prefix, address) } + .maxByOrNull { Cidr.prefixLength(it.prefix) } + + fun estimate(address: String) = + LocationEstimator() + .estimate( + local.values + .filter { Cidr.contains(it.prefix, address) } + .map { MlGeoLocation(it.latitude, it.longitude, it.confidence) } + ) + + override fun close() = dht.close() +} diff --git a/app/src/main/kotlin/rip/crit/commonip/app/MainKt.kt b/app/src/main/kotlin/rip/crit/commonip/app/MainKt.kt new file mode 100644 index 0000000..4e6bf05 --- /dev/null +++ b/app/src/main/kotlin/rip/crit/commonip/app/MainKt.kt @@ -0,0 +1,116 @@ +package rip.crit.commonip.app + +import java.net.InetSocketAddress +import java.nio.file.Path +import java.nio.file.Files +import rip.crit.commonip.decentraland.git.NodeYamlReader +import rip.crit.commonip.decentraland.SshDashboardService +import rip.crit.commonip.decentraland.kademlia.NetworkNode +import rip.crit.commonip.decentraland.kademlia.NodeRegistry +import rip.crit.commonip.decentraland.kademlia.NodeState + +fun main() = CommonIpNode().run() + +private class CommonIpNode { + private val registry = NodeRegistry() + private val cluster = + ClusterGeolocation( + registry, + InetSocketAddress(System.getenv("COMMONIP_DHT_PORT")?.toIntOrNull() ?: 7400), + ) + + fun run() { + val configPath = Path.of(System.getenv("COMMONIP_NODE_CONFIG") ?: "/etc/commonip/node.yaml") + val configuredBootstrap = if (Files.isRegularFile(configPath)) NodeYamlReader.parse(Files.readString(configPath)).bootstrap else emptyList() + cluster.start((configuredBootstrap + System.getenv("COMMONIP_BOOTSTRAP")?.split(',').orEmpty()).mapNotNull(::parseEndpoint).distinct()) + System.getenv("COMMONIP_GEOFEEDS") + ?.split(',') + ?.filter(String::isNotBlank) + ?.forEach(cluster::addFeed) + if (System.getenv("COMMONIP_ENABLE_TAINTED_STARLINK") == "true") cluster.addStarlinkFeed() + cluster.pullAll() + val sshService = + System.getenv("COMMONIP_SSH_PASSWD")?.let { password -> + SshDashboardService( + System.getenv("COMMONIP_SSH_PORT")?.toIntOrNull() ?: 2222, + Path.of(System.getenv("COMMONIP_SSH_HOST_KEY") ?: "/opt/commonip/hostkey.ser"), + System.getenv("COMMONIP_SSH_USER") ?: "commonip", + password.toCharArray(), + ) { + registry.snapshot().joinToString("\n") { "${it.id} ${it.state}" } + } + .also { it.start() } + } + if (System.console() == null) { + while (true) { + Thread.sleep(60_000) + cluster.pullAll() + } + } + sshService?.close() + while (true) { + render() + print("commonip/network> ") + val command = readlnOrNull()?.trim()?.lowercase() ?: return + when (command) { + "q", + "quit", + "exit" -> return + "r", + "refresh" -> cluster.pullAll() + "l", + "lookup" -> lookup() + "e", + "estimate" -> estimate() + else -> println("Commands are r refresh, l lookup, e estimate, q quit.") + } + } + } + + private fun parseEndpoint(value: String): InetSocketAddress? = + value.trim().split(':', limit = 2).let { parts -> + parts.getOrNull(1)?.toIntOrNull()?.let { InetSocketAddress(parts[0], it) } + } + + private fun render() { + print("\u001b[H\u001b[2J") + val nodes = registry.snapshot() + println() + nodes.chunked(8).forEach { row -> + println(row.joinToString(" ") { node -> "${status(node)} ${node.id}" }) + } + println() + registry.feed().forEach { event -> + println( + "${event.at.toString().substring(11, 19)} ${event.nodeId} ${event.action} ${event.detail}" + ) + } + } + + private fun status(node: NetworkNode): String = + when (node.state) { + NodeState.ONLINE -> "\u001b[32m●\u001b[0m" + NodeState.DEGRADED -> "\u001b[33m●\u001b[0m" + NodeState.OFFLINE -> "\u001b[90m●\u001b[0m" + } + + private fun lookup() { + print("IPv4 address> ") + val address = readlnOrNull()?.trim().orEmpty() + val match = cluster.lookup(address) + registry.publish("local", "lookup", match?.prefix ?: "no matching CIDR for $address") + } + + private fun estimate() { + print("CIDR or IPv4 address> ") + val address = readlnOrNull()?.trim().orEmpty() + val result = cluster.estimate(address) + registry.publish( + "local", + "estimate", + result?.let { + "%.4f, %.4f confidence %.2f".format(it.latitude, it.longitude, it.confidence) + } ?: "no coordinate observations", + ) + } +} diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 0000000..8472e96 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + `kotlin-dsl` +} + +repositories { + gradlePluginPortal() +} + +dependencies { + implementation(libs.kotlin.gradle.plugin) + implementation("com.diffplug.spotless:com.diffplug.spotless.gradle.plugin:8.10.2") +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..bd0f7d2 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,14 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This settings file is used to specify which projects to include in your build-logic build. + * This project uses @Incubating APIs which are subject to change. + */ + +dependencyResolutionManagement { + versionCatalogs { + create("libs", { from(files("../gradle/libs.versions.toml")) }) + } +} + +rootProject.name = "build-logic" diff --git a/build-logic/src/main/kotlin/buildlogic.kotlin-application-conventions.gradle.kts b/build-logic/src/main/kotlin/buildlogic.kotlin-application-conventions.gradle.kts new file mode 100644 index 0000000..88b0ae7 --- /dev/null +++ b/build-logic/src/main/kotlin/buildlogic.kotlin-application-conventions.gradle.kts @@ -0,0 +1,11 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + id("buildlogic.kotlin-common-conventions") + + application +} diff --git a/build-logic/src/main/kotlin/buildlogic.kotlin-common-conventions.gradle.kts b/build-logic/src/main/kotlin/buildlogic.kotlin-common-conventions.gradle.kts new file mode 100644 index 0000000..74e7d05 --- /dev/null +++ b/build-logic/src/main/kotlin/buildlogic.kotlin-common-conventions.gradle.kts @@ -0,0 +1,54 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + id("org.jetbrains.kotlin.jvm") + id("com.diffplug.spotless") +} + +repositories { + mavenCentral() +} + +dependencies { + constraints { + implementation("org.apache.commons:commons-text:1.14.0") + } +} + +testing { + suites { + val test = named("test") { + useJUnitJupiter("6.0.1") + } + } +} + +tasks.named("check") { + dependsOn("spotlessCheck") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + vendor = JvmVendorSpec.GRAAL_VM + } +} + +spotless { + kotlin { + target("src/**/*.kt") + ktfmt() + trimTrailingWhitespace() + endWithNewline() + } + kotlinGradle { + target("*.gradle.kts") + ktfmt() + trimTrailingWhitespace() + endWithNewline() + } +} diff --git a/build-logic/src/main/kotlin/buildlogic.kotlin-fmt.gradle.kts b/build-logic/src/main/kotlin/buildlogic.kotlin-fmt.gradle.kts new file mode 100644 index 0000000..a719a07 --- /dev/null +++ b/build-logic/src/main/kotlin/buildlogic.kotlin-fmt.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("com.diffplug.spotless") +} + +spotless { + java { + target("src/**/*.java") + importOrder() + removeUnusedImports() + palantirJavaFormat() + } + + kotlin { + target("src/**/*.kt") + ktlint() + } +} diff --git a/build-logic/src/main/kotlin/buildlogic.kotlin-library-conventions.gradle.kts b/build-logic/src/main/kotlin/buildlogic.kotlin-library-conventions.gradle.kts new file mode 100644 index 0000000..a0091ef --- /dev/null +++ b/build-logic/src/main/kotlin/buildlogic.kotlin-library-conventions.gradle.kts @@ -0,0 +1,11 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + id("buildlogic.kotlin-common-conventions") + + `java-library` +} diff --git a/build-logic/src/main/kotlin/buildlogic/GraalNativeImageTask.kt b/build-logic/src/main/kotlin/buildlogic/GraalNativeImageTask.kt new file mode 100644 index 0000000..a2db726 --- /dev/null +++ b/build-logic/src/main/kotlin/buildlogic/GraalNativeImageTask.kt @@ -0,0 +1,30 @@ +package buildlogic + +import javax.inject.Inject +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.jvm.toolchain.JavaToolchainService +import org.gradle.jvm.toolchain.JvmVendorSpec +import org.gradle.process.ExecOperations + +abstract class GraalNativeImageTask @Inject constructor(private val toolchains: JavaToolchainService, private val exec: ExecOperations) : DefaultTask() { + @get:Classpath abstract val classpath: ListProperty + @get:OutputFile abstract val output: RegularFileProperty + abstract val mainClass: org.gradle.api.provider.Property + + @TaskAction fun build() { + val launcher = toolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(25)) + vendor.set(JvmVendorSpec.GRAAL_VM) + }.get() + val nativeImage = launcher.metadata.installationPath.file("bin/native-image").asFile + check(nativeImage.canExecute()) { "The configured Java 25 toolchain must be GraalVM with native-image installed" } + output.get().asFile.parentFile.mkdirs() + exec.exec { commandLine(nativeImage.absolutePath, "-cp", classpath.get().joinToString(java.io.File.pathSeparator), mainClass.get(), output.get().asFile.absolutePath) } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..005828e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties + +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.java.installations.auto-download=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..d2f0631 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,8 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/version_catalogs.html#sec::toml-dependencies-format + +[versions] +kotlin-gradle-plugin = "2.4.0" + +[libraries] +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-gradle-plugin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..eddabd2eef8d94a5437d6168ff9c87a78ff725b3 GIT binary patch literal 47505 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/node.yaml.example b/node.yaml.example new file mode 100644 index 0000000..0006903 --- /dev/null +++ b/node.yaml.example @@ -0,0 +1,7 @@ +remote: https://ai.crit.rip/automatedintelligence/geofeeds.git +branch: main +lfs: true +tainted: false +passwd: "set-through-secret-manager" +bootstrap: + - "peer.example.net:7400" diff --git a/packages/build.gradle.kts b/packages/build.gradle.kts new file mode 100644 index 0000000..2b76be9 --- /dev/null +++ b/packages/build.gradle.kts @@ -0,0 +1 @@ +plugins { base } diff --git a/packages/decentraland/build.gradle.kts b/packages/decentraland/build.gradle.kts new file mode 100644 index 0000000..06a2626 --- /dev/null +++ b/packages/decentraland/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("buildlogic.kotlin-library-conventions") +} + +dependencies { + api(project(":packages:serialize")) + api(project(":packages:decentraland:kademlia")) + api(project(":packages:decentraland:git")) + implementation("org.apache.sshd:sshd-core:2.15.0") +} diff --git a/packages/decentraland/chksum/build.gradle.kts b/packages/decentraland/chksum/build.gradle.kts new file mode 100644 index 0000000..8c88637 --- /dev/null +++ b/packages/decentraland/chksum/build.gradle.kts @@ -0,0 +1 @@ +plugins { id("buildlogic.kotlin-library-conventions") } diff --git a/packages/decentraland/chksum/src/main/kotlin/rip/crit/commonip/decentraland/chksum/ContentHash.kt b/packages/decentraland/chksum/src/main/kotlin/rip/crit/commonip/decentraland/chksum/ContentHash.kt new file mode 100644 index 0000000..f66033b --- /dev/null +++ b/packages/decentraland/chksum/src/main/kotlin/rip/crit/commonip/decentraland/chksum/ContentHash.kt @@ -0,0 +1,10 @@ +package rip.crit.commonip.decentraland.chksum + +import java.security.MessageDigest + +object ContentHash { + fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256").digest(value.toByteArray()).joinToString("") { + "%02x".format(it) + } +} diff --git a/packages/decentraland/git/build.gradle.kts b/packages/decentraland/git/build.gradle.kts new file mode 100644 index 0000000..fb8e57a --- /dev/null +++ b/packages/decentraland/git/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { id("buildlogic.kotlin-library-conventions") } + +dependencies { api(project(":packages:serialize")) } diff --git a/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/Provenance.kt b/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/Provenance.kt new file mode 100644 index 0000000..c8a29ac --- /dev/null +++ b/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/Provenance.kt @@ -0,0 +1,24 @@ +package rip.crit.commonip.decentraland.git + +import rip.crit.commonip.serialize.GeoLocation + +enum class Taint { + CLEAN, + TAINTED, +} + +data class ProvenancedObservation( + val location: GeoLocation, + val provider: String, + val taint: Taint, + val evidence: Set, +) + +object PromotionPolicy { + fun canPublishToCommunity(value: ProvenancedObservation): Boolean = + value.taint == Taint.CLEAN && value.evidence.size >= 2 + + fun destination(value: ProvenancedObservation): CommonIpRepository = + if (canPublishToCommunity(value)) CommonIpRepositories.community + else CommonIpRepositories.taintedFull +} diff --git a/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/Repository.kt b/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/Repository.kt new file mode 100644 index 0000000..035b88d --- /dev/null +++ b/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/Repository.kt @@ -0,0 +1,67 @@ +package rip.crit.commonip.decentraland.git + +import java.nio.file.Path + +enum class RepositoryTrust { + COMMUNITY, + TAINTED, +} + +data class CommonIpRepository( + val name: String, + val trust: RepositoryTrust, + val graph: RevisionGraph = RevisionGraph(), +) + +object CommonIpRepositories { + val community = CommonIpRepository("commonip/community", RepositoryTrust.COMMUNITY) + val taintedFull = CommonIpRepository("commonip/tainted-full", RepositoryTrust.TAINTED) +} + +data class NodeYaml( + val remote: String, + val branch: String = "main", + val lfs: Boolean = true, + val tainted: Boolean = false, + val passwd: String? = null, + val bootstrap: List = emptyList(), +) + +object NodeYamlReader { + fun parse(text: String): NodeYaml { + val values = + text + .lineSequence() + .map(String::trim) + .filter { ':' in it && !it.startsWith('#') } + .associate { + it.substringBefore(':').trim() to it.substringAfter(':').trim().trim('"', '\'') + } + val bootstrap = text.lineSequence().map(String::trim).filter { it.startsWith("-") }.map { it.removePrefix("-").trim().trim('"', '\'') }.toList() + return NodeYaml( + values["remote"] ?: error("node.yaml requires remote"), + values["branch"] ?: "main", + values["lfs"]?.toBooleanStrictOrNull() ?: true, + values["tainted"]?.toBooleanStrictOrNull() ?: false, + values["passwd"], + bootstrap, + ) + } +} + +class GitLfsSync(private val repository: Path) { + fun sync(config: NodeYaml): Boolean = + runCatching { + fun command(vararg args: String) = + ProcessBuilder(*args) + .directory(repository.toFile()) + .inheritIO() + .start() + .waitFor() == 0 + command("git", "remote", "get-url", "origin") || + command("git", "remote", "add", "origin", config.remote) + command("git", "fetch", "origin", config.branch) && + (!config.lfs || command("git", "lfs", "pull", "origin", config.branch)) + } + .getOrDefault(false) +} diff --git a/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/RevisionGraph.kt b/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/RevisionGraph.kt new file mode 100644 index 0000000..54af434 --- /dev/null +++ b/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/RevisionGraph.kt @@ -0,0 +1,99 @@ +package rip.crit.commonip.decentraland.git + +import java.time.Instant +import java.util.UUID +import rip.crit.commonip.serialize.GeoLocation + +data class GeoCommit( + val id: String = UUID.randomUUID().toString(), + val parents: Set, + val author: String, + val observation: GeoLocation, + val createdAt: Instant = Instant.now(), +) + +enum class MergeState { + FAST_FORWARD, + MERGED, + CONFLICT, +} + +data class MergeResult( + val state: MergeState, + val commit: GeoCommit?, + val competing: List, + val message: String, +) + +class RevisionGraph { + private val commits = linkedMapOf() + + fun import(commit: GeoCommit): Boolean { + if (commit.parents.any { it !in commits }) return false + commits.putIfAbsent(commit.id, commit) + return true + } + + fun commit(author: String, observation: GeoLocation, parents: Set = heads()): GeoCommit = + GeoCommit(parents = parents, author = author, observation = observation).also { + commits[it.id] = it + } + + fun heads(): Set = commits.keys - commits.values.flatMap { it.parents }.toSet() + + fun merge(left: String, right: String, author: String): MergeResult { + val a = + commits[left] + ?: return MergeResult( + MergeState.CONFLICT, + null, + emptyList(), + "left revision is unknown", + ) + val b = + commits[right] + ?: return MergeResult( + MergeState.CONFLICT, + null, + emptyList(), + "right revision is unknown", + ) + if (isAncestor(a.id, b.id)) + return MergeResult(MergeState.FAST_FORWARD, b, emptyList(), "left fast-forwards to right") + if (isAncestor(b.id, a.id)) + return MergeResult(MergeState.FAST_FORWARD, a, emptyList(), "right fast-forwards to left") + if (!compatible(a.observation, b.observation)) + return MergeResult( + MergeState.CONFLICT, + null, + listOf(a, b), + "country or coordinates disagree, proof-of-location required", + ) + val winner = listOf(a, b).maxBy { it.observation.confidence } + val merged = + GeoCommit( + parents = setOf(a.id, b.id), + author = author, + observation = + winner.observation.copy( + source = "merge:$author", + confidence = (a.observation.confidence + b.observation.confidence) / 2, + ), + ) + commits[merged.id] = merged + return MergeResult(MergeState.MERGED, merged, emptyList(), "two verified revisions merged") + } + + private fun isAncestor(ancestor: String, descendant: String): Boolean = + commits[descendant]?.parents?.let { parents -> + ancestor in parents || parents.any { isAncestor(ancestor, it) } + } ?: false + + private fun compatible(a: GeoLocation, b: GeoLocation): Boolean { + val aLat = a.latitude + val bLat = b.latitude + return a.prefix == b.prefix && + a.countryCode == b.countryCode && + (aLat == null || bLat == null || kotlin.math.abs(aLat - bLat) < 1.0) + } +} diff --git a/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/SignedCommitLedger.kt b/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/SignedCommitLedger.kt new file mode 100644 index 0000000..d64f091 --- /dev/null +++ b/packages/decentraland/git/src/main/kotlin/rip/crit/commonip/decentraland/git/SignedCommitLedger.kt @@ -0,0 +1,72 @@ +package rip.crit.commonip.decentraland.git + +import java.nio.charset.StandardCharsets +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Signature +import java.time.Instant +import java.util.Base64 + +data class SignedGeoCommit(val revision: GeoCommit, val signature: String) { + fun payload(): ByteArray = + listOf( + revision.id, + revision.parents.sorted().joinToString(","), + revision.author, + revision.observation.prefix, + revision.observation.countryCode, + revision.observation.latitude, + revision.observation.longitude, + revision.createdAt, + ) + .joinToString("|") + .toByteArray(StandardCharsets.UTF_8) +} + +object CommitSigner { + fun sign(revision: GeoCommit, key: PrivateKey): SignedGeoCommit { + val unsigned = SignedGeoCommit(revision, "") + val signature = + Signature.getInstance("Ed25519") + .apply { + initSign(key) + update(unsigned.payload()) + } + .sign() + return unsigned.copy(signature = Base64.getEncoder().encodeToString(signature)) + } + + fun verify(commit: SignedGeoCommit, key: PublicKey): Boolean = + runCatching { + Signature.getInstance("Ed25519") + .apply { + initVerify(key) + update(commit.payload()) + } + .verify(Base64.getDecoder().decode(commit.signature)) + } + .getOrDefault(false) +} + +data class ProofOfLocation( + val revisionId: String, + val observer: String, + val observedAt: Instant, + val reachable: Boolean, + val latencyMs: Long?, + val signature: String, +) + +class SignedCommitLedger( + private val graph: RevisionGraph, + private val keys: Map, +) { + fun accept(commit: SignedGeoCommit): Boolean = + (keys[commit.revision.author]?.let { CommitSigner.verify(commit, it) } ?: false) && + graph.import(commit.revision) + + fun resolve(left: SignedGeoCommit, right: SignedGeoCommit, author: String): MergeResult { + require(accept(left) && accept(right)) { "all merge inputs must have valid signatures" } + return graph.merge(left.revision.id, right.revision.id, author) + } +} diff --git a/packages/decentraland/git/src/test/kotlin/rip/crit/commonip/decentraland/git/RepositoryTest.kt b/packages/decentraland/git/src/test/kotlin/rip/crit/commonip/decentraland/git/RepositoryTest.kt new file mode 100644 index 0000000..420d332 --- /dev/null +++ b/packages/decentraland/git/src/test/kotlin/rip/crit/commonip/decentraland/git/RepositoryTest.kt @@ -0,0 +1,32 @@ +package rip.crit.commonip.decentraland.git + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import rip.crit.commonip.serialize.GeoLocation + +class RepositoryTest { + @Test + fun `node yaml retains trust and password fields`() { + val config = NodeYamlReader.parse("remote: ssh://example/repo\ntainted: true\npasswd: secret\nbootstrap:\n - peer.example:7400") + assertTrue(config.tainted) + assertEquals("secret", config.passwd) + assertEquals(listOf("peer.example:7400"), config.bootstrap) + } + + @Test + fun `community promotion needs clean independent evidence`() { + val location = GeoLocation("192.0.2.0/24", "US") + assertFalse( + PromotionPolicy.canPublishToCommunity( + ProvenancedObservation(location, "test", Taint.CLEAN, setOf("one")) + ) + ) + assertTrue( + PromotionPolicy.canPublishToCommunity( + ProvenancedObservation(location, "test", Taint.CLEAN, setOf("one", "two")) + ) + ) + } +} diff --git a/packages/decentraland/kademlia/build.gradle.kts b/packages/decentraland/kademlia/build.gradle.kts new file mode 100644 index 0000000..fc8115b --- /dev/null +++ b/packages/decentraland/kademlia/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { id("buildlogic.kotlin-library-conventions") } + +dependencies { + api(project(":packages:serialize")) + implementation("io.ep2p:kademlia-api:5.1.2-RELEASE") + implementation("io.ep2p:kademlia-netty:0.3.2-RELEASE") +} diff --git a/packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/KademliaNode.kt b/packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/KademliaNode.kt new file mode 100644 index 0000000..7d5a90e --- /dev/null +++ b/packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/KademliaNode.kt @@ -0,0 +1,111 @@ +package rip.crit.commonip.decentraland.kademlia + +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetSocketAddress +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.thread + +data class KademliaPeer(val id: String, val address: InetSocketAddress) + +class KademliaNode(private val bind: InetSocketAddress, private val registry: NodeRegistry) : + AutoCloseable { + val id = sha256("${bind.hostString}:${bind.port}") + private val namer = NodeNamer() + private val peers = ConcurrentHashMap() + private val values = ConcurrentHashMap() + private val socket = DatagramSocket(bind) + private val lastOutbound = ConcurrentHashMap() + @Volatile private var running = true + + fun start() = + thread(name = "commonip-kademlia-$id", isDaemon = true) { + registry.register( + NetworkNode( + namer.name(id), + socket.localSocketAddress.toString(), + NodeState.ONLINE, + 0, + values.size, + ) + ) + val bytes = ByteArray(8192) + while (running) runCatching { + val packet = DatagramPacket(bytes, bytes.size) + socket.receive(packet) + handle(packet) + } + } + + fun bootstrap(seed: InetSocketAddress) { + if (peers.values.none { it.address == seed }) send(seed, "PING|$id|${bind.hostString}|${socket.localPort}") + } + + fun store(key: String, value: String) { + values[key] = value + closest(key).forEach { send(it.address, "STORE|$id|$key|${value.encodeBase64()}") } + } + + fun findValue(key: String): String? = values[key] + + fun closest(key: String, count: Int = 20): List = + peers.values.sortedBy { xorDistance(it.id, key) }.take(count) + + private fun handle(packet: DatagramPacket) { + val data = + packet.data + .copyOfRange(packet.offset, packet.offset + packet.length) + .decodeToString() + .split('|', limit = 5) + val remote = InetSocketAddress(packet.address, packet.port) + when (data.firstOrNull()) { + "PING" -> { + peers[data[1]] = KademliaPeer(data[1], InetSocketAddress(data[2], data[3].toInt())) + val name = namer.name(data[1]) + registry.register(NetworkNode(name, remote.toString(), NodeState.ONLINE, null, 0)) + registry.publish(name, "kademlia ping", remote.toString()) + send(remote, "PONG|$id") + } + "PONG" -> { + peers[data[1]] = KademliaPeer(data[1], remote) + val name = namer.name(data[1]) + registry.register(NetworkNode(name, remote.toString(), NodeState.ONLINE, null, 0)) + registry.publish(name, "kademlia bootstrap confirmed", remote.toString()) + } + "STORE" -> values[data[2]] = data[3].decodeBase64() + "FIND_VALUE" -> + values[data[2]]?.let { send(remote, "VALUE|$id|${data[2]}|${it.encodeBase64()}") } + "FIND_NODE" -> + send( + remote, + "NODES|$id|${closest(data[2]).joinToString(";") { "${it.id}@${it.address.hostString}:${it.address.port}" }}", + ) + } + } + + private fun send(target: InetSocketAddress, message: String) { + val key = "${target.hostString}:${target.port}:${message.substringBefore('|')}" + val previous = lastOutbound.put(key, System.currentTimeMillis()) ?: 0 + if (System.currentTimeMillis() - previous < 500) return + socket.send(DatagramPacket(message.encodeToByteArray(), message.length, target)) + } + + override fun close() { + running = false + socket.close() + } + + private fun sha256(value: String) = + MessageDigest.getInstance("SHA-256").digest(value.encodeToByteArray()).joinToString("") { + "%02x".format(it) + } + + private fun xorDistance(left: String, right: String) = + left.zip(right.padEnd(left.length, '0')).count { it.first != it.second } + + private fun String.encodeBase64() = + java.util.Base64.getUrlEncoder().encodeToString(encodeToByteArray()) + + private fun String.decodeBase64() = java.util.Base64.getUrlDecoder().decode(this).decodeToString() +} diff --git a/packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/NodeNamer.kt b/packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/NodeNamer.kt new file mode 100644 index 0000000..7d44adf --- /dev/null +++ b/packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/NodeNamer.kt @@ -0,0 +1,25 @@ +package rip.crit.commonip.decentraland.kademlia + +import java.util.concurrent.ConcurrentHashMap + +class NodeNamer { + private val votes = ConcurrentHashMap>>() + + fun observe(nodeId: String, observerId: String, alias: String) { + votes + .getOrPut(nodeId) { ConcurrentHashMap() } + .getOrPut(alias) { ConcurrentHashMap.newKeySet() } + .add(observerId) + } + + fun name(nodeId: String): String = + votes[nodeId]?.maxByOrNull { it.value.size }?.takeIf { it.value.size >= 2 }?.key + ?: deterministic(nodeId) + + private fun deterministic(id: String): String { + val adjective = arrayOf("amber", "cedar", "delta", "ember", "flint", "grove", "harbor", "iris") + val noun = arrayOf("atlas", "beacon", "cinder", "drift", "echo", "forge", "glade", "horizon") + val number = id.take(8).toLong(16) + return "${adjective[(number % adjective.size).toInt()]}-${noun[((number / adjective.size) % noun.size).toInt()]}-${id.takeLast(4)}" + } +} diff --git a/packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/NodeRegistry.kt b/packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/NodeRegistry.kt new file mode 100644 index 0000000..821bf65 --- /dev/null +++ b/packages/decentraland/kademlia/src/main/kotlin/rip/crit/commonip/decentraland/kademlia/NodeRegistry.kt @@ -0,0 +1,58 @@ +package rip.crit.commonip.decentraland.kademlia + +import java.time.Instant +import java.util.concurrent.CopyOnWriteArrayList + +enum class NodeState { + ONLINE, + DEGRADED, + OFFLINE, +} + +data class NetworkNode( + val id: String, + val endpoint: String, + val state: NodeState, + val latencyMs: Long?, + val records: Int, + val lastSeen: Instant = Instant.now(), +) + +data class NetworkActivity( + val at: Instant, + val nodeId: String, + val action: String, + val detail: String, +) + +class NodeRegistry { + private val nodes = linkedMapOf() + private val activity = CopyOnWriteArrayList() + + @Synchronized + fun register(node: NetworkNode) { + nodes[node.id] = node + publish(node.id, "peer announced", "${node.endpoint} is ${node.state.name.lowercase()}") + } + + @Synchronized + fun update(id: String, state: NodeState, latencyMs: Long? = null, records: Int? = null) { + val prior = nodes[id] ?: return + nodes[id] = + prior.copy( + state = state, + latencyMs = latencyMs ?: prior.latencyMs, + records = records ?: prior.records, + lastSeen = Instant.now(), + ) + publish(id, "status changed", state.name.lowercase()) + } + + fun snapshot(): List = synchronized(this) { nodes.values.sortedBy { it.id } } + + fun feed(limit: Int = 12): List = activity.takeLast(limit).reversed() + + fun publish(nodeId: String, action: String, detail: String) { + activity += NetworkActivity(Instant.now(), nodeId, action, detail) + } +} diff --git a/packages/decentraland/kademlia/src/test/kotlin/rip/crit/commonip/decentraland/kademlia/NodeNamerTest.kt b/packages/decentraland/kademlia/src/test/kotlin/rip/crit/commonip/decentraland/kademlia/NodeNamerTest.kt new file mode 100644 index 0000000..2cb6543 --- /dev/null +++ b/packages/decentraland/kademlia/src/test/kotlin/rip/crit/commonip/decentraland/kademlia/NodeNamerTest.kt @@ -0,0 +1,14 @@ +package rip.crit.commonip.decentraland.kademlia + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class NodeNamerTest { + @Test + fun `two peer votes override deterministic node name`() { + val namer = NodeNamer() + namer.observe("abcdef012345", "peer-a", "north") + namer.observe("abcdef012345", "peer-b", "north") + assertEquals("north", namer.name("abcdef012345")) + } +} diff --git a/packages/decentraland/settings.gradle.kts b/packages/decentraland/settings.gradle.kts new file mode 100644 index 0000000..8e65ae0 --- /dev/null +++ b/packages/decentraland/settings.gradle.kts @@ -0,0 +1,3 @@ +rootProject.name = "commonip-decentraland" + +include("kademlia", "git", "chksum") diff --git a/packages/decentraland/src/main/kotlin/rip/crit/commonip/decentraland/SshDashboardService.kt b/packages/decentraland/src/main/kotlin/rip/crit/commonip/decentraland/SshDashboardService.kt new file mode 100644 index 0000000..0e6171e --- /dev/null +++ b/packages/decentraland/src/main/kotlin/rip/crit/commonip/decentraland/SshDashboardService.kt @@ -0,0 +1,33 @@ +package rip.crit.commonip.decentraland + +import java.nio.file.Path +import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory +import org.apache.sshd.server.SshServer +import org.apache.sshd.server.auth.password.PasswordAuthenticator +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider + +class SshDashboardService( + port: Int, + hostKey: Path, + username: String, + password: CharArray, + private val dashboard: () -> String, +) : AutoCloseable { + private val server = + SshServer.setUpDefaultServer().apply { + this.port = port + keyPairProvider = SimpleGeneratorHostKeyProvider(hostKey) + passwordAuthenticator = PasswordAuthenticator { user, candidate, _ -> + user == username && candidate.toCharArray().contentEquals(password) + } + fileSystemFactory = VirtualFileSystemFactory() + } + + fun start() { + server.start() + } + + fun render(): String = dashboard() + + override fun close() = server.close() +} diff --git a/packages/ml/build.gradle.kts b/packages/ml/build.gradle.kts new file mode 100644 index 0000000..15866f9 --- /dev/null +++ b/packages/ml/build.gradle.kts @@ -0,0 +1,13 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + id("buildlogic.kotlin-library-conventions") +} + +dependencies { + api(project(":packages:serialize")) +} diff --git a/packages/ml/src/main/kotlin/rip/crit/commonip/ml/LocationEstimator.kt b/packages/ml/src/main/kotlin/rip/crit/commonip/ml/LocationEstimator.kt new file mode 100644 index 0000000..4ef43ea --- /dev/null +++ b/packages/ml/src/main/kotlin/rip/crit/commonip/ml/LocationEstimator.kt @@ -0,0 +1,235 @@ +package rip.crit.commonip.ml + +import kotlin.math.* + +data class GeoLocation( + val latitude: Double?, + val longitude: Double?, + val confidence: Double = 1.0, +) + +data class LocationEstimate( + val latitude: Double, + val longitude: Double, + val confidence: Double, + val sampleCount: Int, + val discardedCount: Int, + val spatialSpreadKm: Double, +) + +data class Vector3D(val x: Double, val y: Double, val z: Double) { + fun normalize(): Vector3D { + val mag = sqrt(x * x + y * y + z * z) + return if (mag == 0.0) Vector3D(0.0, 0.0, 0.0) else Vector3D(x / mag, y / mag, z / mag) + } + + fun toGeoLocation(confidence: Double = 1.0): GeoLocation { + val norm = this.normalize() + val lat = Math.toDegrees(asin(norm.z.coerceIn(-1.0, 1.0))) + val lon = Math.toDegrees(atan2(norm.y, norm.x)) + return GeoLocation(lat, lon, confidence) + } +} + +fun GeoLocation.toVector3D(): Vector3D { + val latRad = Math.toRadians(this.latitude!!) + val lonRad = Math.toRadians(this.longitude!!) + return Vector3D( + x = cos(latRad) * cos(lonRad), + y = cos(latRad) * sin(lonRad), + z = sin(latRad), + ) +} + +class LocationEstimator( + private val dbscanEpsKm: Double = 250.0, + private val dbscanMinPts: Int = 2, + private val medianIterations: Int = 20, +) { + + fun estimate(observations: List): LocationEstimate? { + val validPoints = observations.filter { it.latitude != null && it.longitude != null } + if (validPoints.isEmpty()) return null + + val clusteredPoints = dbscanCluster(validPoints, dbscanEpsKm, dbscanMinPts) + val selected = clusteredPoints.ifEmpty { validPoints } + + val (estLat, estLon) = computeGeometricMedian(selected, medianIterations) + + val distances = selected.map { distanceKm(estLat, estLon, it.latitude!!, it.longitude!!) } + val standardDistanceKm = sqrt(distances.sumOf { it * it } / selected.size) + + val meanSampleConfidence = selected.map { max(it.confidence, 0.01) }.average() + val densityFactor = 1.0 / (1.0 + exp(-(selected.size - 3.0) / 2.0)) + val spreadPenalty = 1.0 / (1.0 + (standardDistanceKm / 300.0)) + + val overallConfidence = + (meanSampleConfidence * densityFactor * spreadPenalty).coerceIn(0.0, 1.0) + + return LocationEstimate( + latitude = estLat, + longitude = estLon, + confidence = overallConfidence, + sampleCount = selected.size, + discardedCount = validPoints.size - selected.size, + spatialSpreadKm = standardDistanceKm, + ) + } + + /** Finds the primary spatial cluster using DBSCAN logic. */ + private fun dbscanCluster( + points: List, + epsKm: Double, + minPts: Int, + ): List { + val visited = BooleanArray(points.size) + val clusters = mutableListOf>() + + for (i in points.indices) { + if (visited[i]) continue + visited[i] = true + + val neighbors = getNeighbors(points, i, epsKm) + if (neighbors.size >= minPts) { + val currentCluster = mutableListOf(points[i]) + val queue = neighbors.toMutableList() + var qIndex = 0 + + while (qIndex < queue.size) { + val pIndex = queue[qIndex++] + if (!visited[pIndex]) { + visited[pIndex] = true + val subNeighbors = getNeighbors(points, pIndex, epsKm) + if (subNeighbors.size >= minPts) { + queue.addAll(subNeighbors.filter { !queue.contains(it) }) + } + } + if (!currentCluster.contains(points[pIndex])) { + currentCluster.add(points[pIndex]) + } + } + clusters.add(currentCluster) + } + } + + return clusters.maxByOrNull { cluster -> cluster.sumOf { max(it.confidence, 0.05) } } + ?: emptyList() + } + + private fun getNeighbors(points: List, centerIdx: Int, epsKm: Double): List { + val center = points[centerIdx] + val result = mutableListOf() + for (i in points.indices) { + if ( + distanceKm( + center.latitude!!, + center.longitude!!, + points[i].latitude!!, + points[i].longitude!!, + ) <= epsKm + ) { + result.add(i) + } + } + return result + } + + /** Weiszfeld's algorithm for finding the spatial geometric median (L1 median). */ + private fun computeGeometricMedian( + points: List, + iterations: Int, + ): Pair { + if (points.size == 1) return Pair(points[0].latitude!!, points[0].longitude!!) + + val weights = points.map { max(it.confidence, 0.05) } + val sumWeight = weights.sum() + + var currentVector = + points + .zip(weights) + .map { (pt, w) -> + val vec = pt.toVector3D() + Vector3D(vec.x * w, vec.y * w, vec.z * w) + } + .reduce { acc, v -> + Vector3D(acc.x + v.x, acc.y + v.y, acc.z + v.z) + } + .let { Vector3D(it.x / sumWeight, it.y / sumWeight, it.z / sumWeight).normalize() } + + var currentGeo = currentVector.toGeoLocation() + + repeat(iterations) { + var sumX = 0.0 + var sumY = 0.0 + var sumZ = 0.0 + var totalW = 0.0 + + for (i in points.indices) { + val ptVec = points[i].toVector3D() + val dist = + max( + distanceKm( + currentGeo.latitude!!, + currentGeo.longitude!!, + points[i].latitude!!, + points[i].longitude!!, + ), + 0.1, + ) + val invWeight = weights[i] / dist + + sumX += ptVec.x * invWeight + sumY += ptVec.y * invWeight + sumZ += ptVec.z * invWeight + totalW += invWeight + } + + if (totalW > 0.0) { + currentVector = Vector3D(sumX / totalW, sumY / totalW, sumZ / totalW).normalize() + currentGeo = currentVector.toGeoLocation() + } + } + + return Pair(currentGeo.latitude!!, currentGeo.longitude!!) + } + + companion object { + fun distanceKm(aLat: Double, aLon: Double, bLat: Double, bLon: Double): Double { + val dLat = Math.toRadians(bLat - aLat) + val dLon = Math.toRadians(bLon - aLon) + val h = + sin(dLat / 2).pow(2) + + cos(Math.toRadians(aLat)) * cos(Math.toRadians(bLat)) * sin(dLon / 2).pow(2) + return 6371.0 * 2 * atan2(sqrt(h), sqrt(1 - h)) + } + } +} + +data class NodeTarget( + val nodeId: String, + val capacity: Int, + val latitude: Double?, + val longitude: Double?, +) + +object LocationDispatcher { + fun select( + nodes: List, + estimate: LocationEstimate, + count: Int = 3, + ): List = + nodes + .filter { it.capacity > 0 && it.latitude != null && it.longitude != null } + .sortedBy { node -> + val distance = + LocationEstimator.distanceKm( + estimate.latitude, + estimate.longitude, + node.latitude!!, + node.longitude!!, + ) + + distance / sqrt(node.capacity.toDouble()) + } + .take(count) +} diff --git a/packages/ml/src/test/kotlin/rip/crit/commonip/ml/LocationEstimatorTest.kt b/packages/ml/src/test/kotlin/rip/crit/commonip/ml/LocationEstimatorTest.kt new file mode 100644 index 0000000..929a990 --- /dev/null +++ b/packages/ml/src/test/kotlin/rip/crit/commonip/ml/LocationEstimatorTest.kt @@ -0,0 +1,20 @@ +package rip.crit.commonip.ml + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class LocationEstimatorTest { + @Test + fun `estimator rejects distant outlier`() { + val result = + LocationEstimator() + .estimate( + listOf( + GeoLocation(40.0, -75.0, 0.9), + GeoLocation(40.1, -75.1, 0.9), + GeoLocation(-33.0, 151.0, 0.9), + ) + )!! + assertEquals(1, result.discardedCount) + } +} diff --git a/packages/rawr/build.gradle.kts b/packages/rawr/build.gradle.kts new file mode 100644 index 0000000..15866f9 --- /dev/null +++ b/packages/rawr/build.gradle.kts @@ -0,0 +1,13 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + id("buildlogic.kotlin-library-conventions") +} + +dependencies { + api(project(":packages:serialize")) +} diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RFC8805.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RFC8805.kt new file mode 100644 index 0000000..5d113a1 --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RFC8805.kt @@ -0,0 +1,9 @@ +package rip.crit.commonip.rawr.feeds + +import rip.crit.commonip.serialize.GeoFeed +import rip.crit.commonip.serialize.GeoFeedCodec +import rip.crit.commonip.serialize.GeoFeedFormat + +object RFC8805 { + fun parse(csv: String): GeoFeed = GeoFeedCodec.decode(csv, GeoFeedFormat.CSV) +} diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RFC9632.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RFC9632.kt new file mode 100644 index 0000000..f3e6acc --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RFC9632.kt @@ -0,0 +1,20 @@ +package rip.crit.commonip.rawr.feeds + +data class RFC9632( + val feedUrl: String, + val signatureUrl: String?, + val valid: Boolean, + val signer: String? = null, +) { + companion object { + fun fromDnsRecord(record: String): RFC9632? { + val values = + record + .split(';') + .map { it.trim() } + .associate { it.substringBefore('=').lowercase() to it.substringAfter('=', "") } + val url = values["geofeed"] ?: return null + return RFC9632(url, values["sig"], values["valid"] != "false", values["signer"]) + } + } +} diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RirFeed.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RirFeed.kt new file mode 100644 index 0000000..80404c8 --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/feeds/RirFeed.kt @@ -0,0 +1,37 @@ +package rip.crit.commonip.rawr.feeds + +import rip.crit.commonip.rawr.types.Ingest +import rip.crit.commonip.serialize.GeoFeed +import rip.crit.commonip.serialize.GeoFeedCodec +import rip.crit.commonip.serialize.GeoFeedFormat +import rip.crit.commonip.serialize.GeoLocation + +class RirFeed(private val name: String, private val loader: () -> String) : Ingest { + override fun ingest(): GeoFeed = + GeoFeedCodec.decode(loader(), GeoFeedFormat.CSV).let { feed -> + feed.copy(records = feed.records.map { it.copy(source = name) }) + } +} + +object DelegatedStats { + fun parse(payload: String, source: String): GeoFeed = + GeoFeed( + payload + .lineSequence() + .filter { it.isNotBlank() && !it.startsWith('#') && !it.startsWith("2|") } + .mapNotNull { line -> + val f = line.split('|') + if (f.size < 5 || f[2].lowercase() !in setOf("ipv4", "ipv6") || f[1] == "*") null + else { + val prefix = + if (f[2].lowercase() == "ipv4") "${f[3]}/${prefixFromCount(f[4])}" + else "${f[3]}/${f.getOrElse(4) { "48" }}" + GeoLocation(prefix, f[1].uppercase(), source = source, confidence = 0.25) + } + } + .toList() + ) + + private fun prefixFromCount(count: String): Int = + 32 - Integer.numberOfTrailingZeros(count.toIntOrNull() ?: 1) +} diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/AFRINIC.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/AFRINIC.kt new file mode 100644 index 0000000..0f995d7 --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/AFRINIC.kt @@ -0,0 +1,8 @@ +package rip.crit.commonip.rawr.providers + +class AFRINIC(loader: (() -> String)? = null) : + RirProvider( + "AFRINIC", + "https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest", + loader, + ) diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/APNIC.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/APNIC.kt new file mode 100644 index 0000000..315243f --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/APNIC.kt @@ -0,0 +1,8 @@ +package rip.crit.commonip.rawr.providers + +class APNIC(loader: (() -> String)? = null) : + RirProvider( + "APNIC", + "https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest", + loader, + ) diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/ARIN.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/ARIN.kt new file mode 100644 index 0000000..ccf8521 --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/ARIN.kt @@ -0,0 +1,8 @@ +package rip.crit.commonip.rawr.providers + +class ARINIngest(loader: (() -> String)? = null) : + RirProvider( + "ARIN", + "https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest", + loader, + ) diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/LACNIC.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/LACNIC.kt new file mode 100644 index 0000000..0dec57e --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/LACNIC.kt @@ -0,0 +1,8 @@ +package rip.crit.commonip.rawr.providers + +class LACNIC(loader: (() -> String)? = null) : + RirProvider( + "LACNIC", + "https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest", + loader, + ) diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RIPENCC.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RIPENCC.kt new file mode 100644 index 0000000..1456ec7 --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RIPENCC.kt @@ -0,0 +1,8 @@ +package rip.crit.commonip.rawr.providers + +class RIPENCC(loader: (() -> String)? = null) : + RirProvider( + "RIPE NCC", + "https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest", + loader, + ) diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RirGeoFeedLoader.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RirGeoFeedLoader.kt new file mode 100644 index 0000000..1eecca9 --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RirGeoFeedLoader.kt @@ -0,0 +1,49 @@ +package rip.crit.commonip.rawr.providers + +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import rip.crit.commonip.serialize.GeoFeed +import rip.crit.commonip.serialize.GeoFeedCodec +import rip.crit.commonip.serialize.GeoFeedFormat + +enum class RirMetadataField(val keys: Set) { + REMARKS(setOf("remarks", "remark")), + COMMENT(setOf("comment", "comments")), +} + +class RirGeoFeedLoader( + private val source: String, + private val acceptedFields: Set, + private val fetch: (String) -> String = RirGeoFeedLoader::httpGet, +) { + fun urls(registryObject: String): Set = + registryObject + .lineSequence() + .map(String::trim) + .filter { ':' in it } + .filter { line -> + acceptedFields.any { field -> + line.substringBefore(':').trim().lowercase() in field.keys + } + } + .flatMap { urlPattern.findAll(it).map { match -> match.value } } + .toSet() + + fun pull(registryObject: String): List = + urls(registryObject).map { url -> GeoFeedCodec.decode(fetch(url), GeoFeedFormat.CSV) } + + private companion object { + val urlPattern = Regex("https?://[^\\s,;\"]+") + val client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build() + + fun httpGet(url: String): String = + client + .send( + HttpRequest.newBuilder(URI(url)).GET().build(), + HttpResponse.BodyHandlers.ofString(), + ) + .body() + } +} diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RirProvider.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RirProvider.kt new file mode 100644 index 0000000..3f11e08 --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/providers/RirProvider.kt @@ -0,0 +1,31 @@ +package rip.crit.commonip.rawr.providers + +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import rip.crit.commonip.rawr.feeds.DelegatedStats +import rip.crit.commonip.rawr.types.Ingest +import rip.crit.commonip.serialize.GeoFeed + +abstract class RirProvider( + private val name: String, + private val endpoint: String, + private val loader: (() -> String)? = null, +) : Ingest { + override fun ingest(): GeoFeed = + DelegatedStats.parse( + loader?.invoke() + ?: client + .send( + HttpRequest.newBuilder(URI(endpoint)).GET().build(), + HttpResponse.BodyHandlers.ofString(), + ) + .body(), + name, + ) + + private companion object { + val client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build() + } +} diff --git a/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/types/Ingest.kt b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/types/Ingest.kt new file mode 100644 index 0000000..5446215 --- /dev/null +++ b/packages/rawr/src/main/kotlin/rip/crit/commonip/rawr/types/Ingest.kt @@ -0,0 +1,36 @@ +package rip.crit.commonip.rawr.types + +import rip.crit.commonip.serialize.GeoFeed +import rip.crit.commonip.serialize.GeoLocation + +interface Ingest { + fun ingest(): GeoFeed + + fun lookupOne(address: String): GeoLocation? = + ingest() + .records + .filter { Cidr.contains(it.prefix, address) } + .maxByOrNull { Cidr.prefixLength(it.prefix) } +} + +object Cidr { + fun prefixLength(cidr: String): Int = cidr.substringAfter('/').toIntOrNull() ?: -1 + + fun contains(cidr: String, address: String): Boolean { + val parts = cidr.split('/', limit = 2) + val bits = parts.getOrNull(1)?.toIntOrNull() ?: return false + if (bits !in 0..32) return false + val network = ipv4(parts[0]) ?: return false + val target = ipv4(address) ?: return false + val mask = if (bits == 0) 0 else (-1 shl (32 - bits)) + return network and mask == target and mask + } + + private fun ipv4(value: String): Int? { + val fields = value.split('.') + if (fields.size != 4) return null + return fields.fold(0) { result, field -> + field.toIntOrNull()?.takeIf { it in 0..255 }?.let { (result shl 8) or it } ?: return null + } + } +} diff --git a/packages/rawr/src/test/kotlin/rip/crit/commonip/rawr/providers/RirProviderTest.kt b/packages/rawr/src/test/kotlin/rip/crit/commonip/rawr/providers/RirProviderTest.kt new file mode 100644 index 0000000..8dbb56d --- /dev/null +++ b/packages/rawr/src/test/kotlin/rip/crit/commonip/rawr/providers/RirProviderTest.kt @@ -0,0 +1,13 @@ +package rip.crit.commonip.rawr.providers + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class RirProviderTest { + @Test + fun `arin parses delegated ipv4 fixture`() { + val feed = ARINIngest { "arin|US|ipv4|192.0.2.0|256|20260101|allocated" }.ingest() + assertEquals("192.0.2.0/24", feed.records.single().prefix) + assertEquals("US", feed.records.single().countryCode) + } +} diff --git a/packages/serialize/build.gradle.kts b/packages/serialize/build.gradle.kts new file mode 100644 index 0000000..60237da --- /dev/null +++ b/packages/serialize/build.gradle.kts @@ -0,0 +1,9 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This project uses @Incubating APIs which are subject to change. + */ + +plugins { + id("buildlogic.kotlin-library-conventions") +} diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/Deserialize.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/Deserialize.kt new file mode 100644 index 0000000..ba66dd4 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/Deserialize.kt @@ -0,0 +1,3 @@ +package rip.crit.commonip.serialize + +fun String.deserializeGeoFeed(format: GeoFeedFormat): GeoFeed = GeoFeedCodec.decode(this, format) diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/GeoFeed.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/GeoFeed.kt new file mode 100644 index 0000000..116fd70 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/GeoFeed.kt @@ -0,0 +1,34 @@ +package rip.crit.commonip.serialize + +import java.time.Instant + +data class GeoLocation( + val prefix: String, + val countryCode: String, + val region: String? = null, + val city: String? = null, + val postalCode: String? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val source: String = "unknown", + val observedAt: Instant = Instant.now(), + val confidence: Double = 0.5, +) { + init { + require('/' in prefix) { "prefix must be CIDR notation" } + require(countryCode.length == 2) { "countryCode must be ISO 3166-1 alpha-2" } + require(confidence in 0.0..1.0) { "confidence must be between 0 and 1" } + } +} + +data class GeoFeed( + val records: List, + val generatedAt: Instant = Instant.now(), + val schemaVersion: String = "commonip/geofeed/v1", +) + +enum class GeoFeedFormat { + CSV, + JSON, + XML, +} diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/GeoFeedCodec.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/GeoFeedCodec.kt new file mode 100644 index 0000000..5d16793 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/GeoFeedCodec.kt @@ -0,0 +1,158 @@ +package rip.crit.commonip.serialize + +import java.time.Instant + +object GeoFeedCodec { + fun encode(feed: GeoFeed, format: GeoFeedFormat): String = + when (format) { + GeoFeedFormat.CSV -> + feed.records.joinToString("\n") { record -> + listOf( + record.prefix, + record.countryCode, + record.region.orEmpty(), + record.city.orEmpty(), + record.postalCode.orEmpty(), + ) + .joinToString(",") { csv(it) } + } + GeoFeedFormat.JSON -> + "{\"schema\":\"${feed.schemaVersion}\",\"generatedAt\":\"${feed.generatedAt}\",\"records\":[" + + feed.records.joinToString(",") { jsonRecord(it) } + + "]}" + GeoFeedFormat.XML -> + "" + + feed.records.joinToString("") { record -> + "" + } + + "" + } + + fun decode(payload: String, format: GeoFeedFormat): GeoFeed = + when (format) { + GeoFeedFormat.CSV -> + GeoFeed( + payload + .lineSequence() + .filter { it.isNotBlank() && !it.trimStart().startsWith("#") } + .map { csvRecord(it) } + .toList() + ) + GeoFeedFormat.JSON -> + GeoFeed( + jsonObjects( + payload.substringAfter("\"records\":[", "").substringBeforeLast("]", "") + ) + .map { jsonToRecord(it) } + ) + GeoFeedFormat.XML -> + GeoFeed( + Regex("]+)/>") + .findAll(payload) + .map { xmlToRecord(it.groupValues[1]) } + .toList() + ) + } + + private fun csvRecord(line: String): GeoLocation { + val fields = splitCsv(line) + require(fields.size >= 2) { "RFC 8805 record needs a prefix and country" } + return GeoLocation( + fields[0], + fields[1].uppercase(), + fields.getOrNull(2).blankToNull(), + fields.getOrNull(3).blankToNull(), + fields.getOrNull(4).blankToNull(), + source = "rfc8805", + ) + } + + private fun jsonRecord(r: GeoLocation) = + "{" + + listOf( + "prefix" to r.prefix, + "countryCode" to r.countryCode, + "region" to r.region, + "city" to r.city, + "postalCode" to r.postalCode, + "latitude" to r.latitude, + "longitude" to r.longitude, + "source" to r.source, + "observedAt" to r.observedAt.toString(), + "confidence" to r.confidence, + ) + .joinToString(",") { (k, v) -> + "\"$k\":" + + when (v) { + null -> "null" + is Number -> v.toString() + else -> "\"${json(v.toString())}\"" + } + } + + "}" + + private fun jsonToRecord(obj: String): GeoLocation { + fun value(name: String) = + Regex("\"$name\"\\s*:\\s*(null|\"((?:\\\\.|[^\"])*)\"|[-.0-9]+)").find(obj)?.let { + if (it.groupValues[1] == "null") null + else + it.groupValues[2] + .ifEmpty { it.groupValues[1] } + .replace("\\\"", "\"") + .replace("\\\\", "\\") + } + return GeoLocation( + value("prefix") ?: error("JSON record missing prefix"), + value("countryCode") ?: error("JSON record missing countryCode"), + value("region"), + value("city"), + value("postalCode"), + value("latitude")?.toDouble(), + value("longitude")?.toDouble(), + value("source") ?: "commonip", + value("observedAt")?.let(Instant::parse) ?: Instant.now(), + value("confidence")?.toDouble() ?: 0.5, + ) + } + + private fun xmlToRecord(attrs: String): GeoLocation { + fun value(name: String) = + Regex("$name=\"([^\"]*)\"").find(attrs)?.groupValues?.get(1)?.let { + it.replace(""", "\"").replace("&", "&") + } + return GeoLocation( + value("prefix") ?: error("XML record missing prefix"), + value("country") ?: error("XML record missing country"), + value("region"), + value("city"), + value("postalCode"), + value("latitude")?.toDouble(), + value("longitude")?.toDouble(), + value("source") ?: "commonip", + value("observedAt")?.let(Instant::parse) ?: Instant.now(), + value("confidence")?.toDouble() ?: 0.5, + ) + } + + private fun jsonObjects(raw: String): List = + Regex("\\{(?:[^{}]|\\{[^{}]*})*}").findAll(raw).map { it.value }.toList() + + private fun splitCsv(line: String): List = + Regex("(?:^|,)(\"(?:[^\"]|\"\")*\"|[^,]*)") + .findAll(line) + .map { it.groupValues[1].removeSurrounding("\"").replace("\"\"", "\"") } + .toList() + + private fun csv(value: String) = + if (value.any { it == ',' || it == '"' || it == '\n' }) "\"${value.replace("\"", "\"\"")}\"" + else value + + private fun json(value: String) = value.replace("\\", "\\\\").replace("\"", "\\\"") + + private fun xml(value: String) = value.replace("&", "&").replace("\"", """) + + private fun attr(name: String, value: Any?) = + value?.let { " $name=\"${xml(it.toString())}\"" }.orEmpty() + + private fun String?.blankToNull() = this?.takeIf { it.isNotBlank() } +} diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/Serialize.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/Serialize.kt new file mode 100644 index 0000000..c149fa3 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/Serialize.kt @@ -0,0 +1,3 @@ +package rip.crit.commonip.serialize + +fun GeoFeed.serialize(format: GeoFeedFormat): String = GeoFeedCodec.encode(this, format) diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/CSV.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/CSV.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/CSV.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/JSON.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/JSON.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/JSON.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/XML.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/XML.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/cityStateCountry/XML.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/CSVSpec.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/CSVSpec.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/CSVSpec.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/JSONSpec.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/JSONSpec.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/JSONSpec.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/Schema.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/Schema.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/Schema.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/XMLSpec.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/XMLSpec.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/common/XMLSpec.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/CSV.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/CSV.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/CSV.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/JSON.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/JSON.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/JSON.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/XML.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/XML.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/country/XML.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/CSV.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/CSV.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/CSV.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/JSON.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/JSON.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/JSON.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/XML.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/XML.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/stateCountry/XML.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/CSV.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/CSV.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/CSV.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/JSON.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/JSON.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/JSON.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/XML.kt b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/XML.kt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/serialize/src/main/kotlin/rip/crit/commonip/serialize/zipCityStateCountry/XML.kt @@ -0,0 +1 @@ + diff --git a/packages/serialize/src/test/kotlin/rip/crit/commonip/serialize/GeoFeedCodecTest.kt b/packages/serialize/src/test/kotlin/rip/crit/commonip/serialize/GeoFeedCodecTest.kt new file mode 100644 index 0000000..b7e1e19 --- /dev/null +++ b/packages/serialize/src/test/kotlin/rip/crit/commonip/serialize/GeoFeedCodecTest.kt @@ -0,0 +1,15 @@ +package rip.crit.commonip.serialize + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class GeoFeedCodecTest { + @Test + fun `RFC 8805 records round trip`() { + val feed = GeoFeed(listOf(GeoLocation("192.0.2.0/24", "US", "PA", "Philadelphia", "19106"))) + val parsed = + GeoFeedCodec.decode(GeoFeedCodec.encode(feed, GeoFeedFormat.CSV), GeoFeedFormat.CSV) + assertEquals(feed.records.first().prefix, parsed.records.first().prefix) + assertEquals("Philadelphia", parsed.records.first().city) + } +} diff --git a/packages/settings.gradle.kts b/packages/settings.gradle.kts new file mode 100644 index 0000000..2d88bcd --- /dev/null +++ b/packages/settings.gradle.kts @@ -0,0 +1,3 @@ + +rootProject.name = "commonip-packages" +include("decentraland", "serialize", "ml", "rawr") \ No newline at end of file diff --git a/proprietary/build.gradle.kts b/proprietary/build.gradle.kts new file mode 100644 index 0000000..d0702b9 --- /dev/null +++ b/proprietary/build.gradle.kts @@ -0,0 +1,74 @@ +import java.net.URI +import javax.xml.parsers.DocumentBuilderFactory +import org.w3c.dom.Element +import org.w3c.dom.Node + +plugins { + id("buildlogic.kotlin-library-conventions") +} + +dependencies { + api(project(":packages:serialize")) + implementation(project(":packages:rawr")) + implementation("commons-net:commons-net:3.11.1") +} + +tasks.register("whois") { + group = "generation" + description = "Fetches latest WHOIS server mapping and generates WhoisServers.kt in source." + + val outputDirectory = file("src/main/kotlin/rip/crit/commonip/proprietary") + outputs.file(outputDirectory.resolve("WhoisServers.kt")) + + doLast { + val xmlUrl = + "https://raw.githubusercontent.com/whois-server-list/whois-server-list/refs/heads/master/whois-server-list.xml" + + val inputStream = URI(xmlUrl).toURL().openStream() + val dbFactory = DocumentBuilderFactory.newInstance() + val dBuilder = dbFactory.newDocumentBuilder() + val xmlDoc = dBuilder.parse(inputStream) + xmlDoc.documentElement.normalize() + + val serverMap = mutableMapOf() + val domainNodes = xmlDoc.getElementsByTagName("domain") + + for (i in 0 until domainNodes.length) { + val domainNode = domainNodes.item(i) + if (domainNode.nodeType == Node.ELEMENT_NODE) { + val domainElement = domainNode as Element + val domainName = domainElement.getAttribute("name") + + val whoisServerNodes = domainElement.getElementsByTagName("whoisServer") + if (whoisServerNodes.length > 0) { + val whoisServerElement = whoisServerNodes.item(0) as Element + val host = whoisServerElement.getAttribute("host") + if (host.isNotBlank() && domainName.isNotBlank()) { + serverMap[domainName] = host + } + } + } + } + + val mapEntries = StringBuilder() + serverMap.forEach { (tld, server) -> + mapEntries.append(" \"$tld\" to \"$server\",\n") + } + + val outputFile = outputDirectory.resolve("WhoisServers.kt") + outputFile.parentFile.mkdirs() + outputFile.writeText( + """ + package rip.crit.commonip.proprietary + + // Auto-generated file. Do not edit directly. + object WhoisServers { + val map: Map = mapOf( + $mapEntries ) + } + """ + .trimIndent() + ) + println("Successfully generated ${outputFile.path} with ${serverMap.size} active TLD servers.") + } +} diff --git a/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/GeoProvider.kt b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/GeoProvider.kt new file mode 100644 index 0000000..07ab39b --- /dev/null +++ b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/GeoProvider.kt @@ -0,0 +1,19 @@ +package rip.crit.commonip.proprietary + +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +fun interface HttpTransport { + fun get(url: String, headers: Map): String +} + +object DefaultHttpTransport : HttpTransport { + private val client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build() + + override fun get(url: String, headers: Map): String { + val request = HttpRequest.newBuilder(URI(url)).apply { headers.forEach(::header) }.GET().build() + return client.send(request, HttpResponse.BodyHandlers.ofString()).body() + } +} diff --git a/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/OpenGeoFeed.kt b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/OpenGeoFeed.kt new file mode 100644 index 0000000..e220788 --- /dev/null +++ b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/OpenGeoFeed.kt @@ -0,0 +1,18 @@ +package rip.crit.commonip.proprietary + +import rip.crit.commonip.rawr.types.Cidr +import rip.crit.commonip.serialize.GeoFeed +import rip.crit.commonip.serialize.GeoFeedCodec +import rip.crit.commonip.serialize.GeoFeedFormat +import rip.crit.commonip.serialize.GeoLocation + +class OpenGeoFeed(private val transport: HttpTransport = DefaultHttpTransport) { + fun pull(url: String): GeoFeed = + GeoFeedCodec.decode(transport.get(url, emptyMap()), GeoFeedFormat.CSV) + + fun lookup(url: String, address: String): GeoLocation? = + pull(url) + .records + .filter { Cidr.contains(it.prefix, address) } + .maxByOrNull { Cidr.prefixLength(it.prefix) } +} diff --git a/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/Rdap.kt b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/Rdap.kt new file mode 100644 index 0000000..3025869 --- /dev/null +++ b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/Rdap.kt @@ -0,0 +1,6 @@ +package rip.crit.commonip.proprietary + +class Rdap(private val transport: HttpTransport = DefaultHttpTransport) { + fun lookup(address: String): String = + transport.get("https://rdap.org/ip/$address", mapOf("Accept" to "application/rdap+json")) +} diff --git a/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/RequestGovernor.kt b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/RequestGovernor.kt new file mode 100644 index 0000000..bd75434 --- /dev/null +++ b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/RequestGovernor.kt @@ -0,0 +1,31 @@ +package rip.crit.commonip.proprietary + +import java.time.Duration +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Semaphore + +class RequestGovernor( + private val minimumInterval: Duration = Duration.ofMillis(500), + parallelism: Int = 2, +) { + private val permits = Semaphore(parallelism) + private val lastRequest = ConcurrentHashMap() + + fun execute(provider: String, action: () -> T): T { + permits.acquire() + try { + synchronized(lastRequest) { + val wait = + minimumInterval.toMillis() - (System.currentTimeMillis() - (lastRequest[provider] ?: 0)) + if (wait > 0) Thread.sleep(wait) + lastRequest[provider] = System.currentTimeMillis() + } + return action() + } finally { + permits.release() + } + } + + fun chunks(values: Collection, size: Int = 100): Sequence> = + values.asSequence().chunked(size) +} diff --git a/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/StarlinkGeoFeed.kt b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/StarlinkGeoFeed.kt new file mode 100644 index 0000000..2e25a0f --- /dev/null +++ b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/StarlinkGeoFeed.kt @@ -0,0 +1,13 @@ +package rip.crit.commonip.proprietary + +import rip.crit.commonip.serialize.GeoFeed +import rip.crit.commonip.serialize.GeoFeedCodec +import rip.crit.commonip.serialize.GeoFeedFormat + +class StarlinkGeoFeed(private val transport: HttpTransport = DefaultHttpTransport) { + companion object { + const val URL = "https://geoip.starlinkisp.net/feed.csv" + } + + fun pull(): GeoFeed = GeoFeedCodec.decode(transport.get(URL, emptyMap()), GeoFeedFormat.CSV) +} diff --git a/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/Whois.kt b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/Whois.kt new file mode 100644 index 0000000..232ab42 --- /dev/null +++ b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/Whois.kt @@ -0,0 +1,17 @@ +package rip.crit.commonip.proprietary + +import org.apache.commons.net.whois.WhoisClient + +class Whois(private val servers: Map = WhoisServers.map) { + fun lookup(query: String): String { + val server = + servers[query.substringAfterLast('.', "").lowercase()] ?: servers.getValue("default") + val client = WhoisClient() + return try { + client.connect(server) + client.query("$query\r\n") + } finally { + if (client.isConnected) client.disconnect() + } + } +} diff --git a/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/WhoisServers.kt b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/WhoisServers.kt new file mode 100644 index 0000000..20874ab --- /dev/null +++ b/proprietary/src/main/kotlin/rip/crit/commonip/proprietary/WhoisServers.kt @@ -0,0 +1,1535 @@ +package rip.crit.commonip.proprietary + +// Auto-generated file. Do not edit directly. +object WhoisServers { + val map: Map = + mapOf( + "aarp" to "whois.nic.aarp", + "abarth" to "whois.afilias-srs.net", + "abbott" to "whois.afilias-srs.net", + "abbvie" to "whois.afilias-srs.net", + "abc" to "whois.nic.abc", + "abogado" to "whois-dub.mm-registry.com", + "abudhabi" to "whois.nic.abudhabi", + "ac" to "whois.nic.ac", + "ac.ac" to "whois.nic.ac", + "co.ac" to "whois.nic.ac", + "gv.ac" to "whois.nic.ac", + "or.ac" to "whois.nic.ac", + "academy" to "whois.donuts.co", + "accountant" to "whois.nic.accountant", + "accountants" to "whois.donuts.co", + "aco" to "whois.afilias-srs.net", + "active" to "whois.afilias-srs.net", + "actor" to "whois.unitedtld.com", + "ad" to "whois.ripe.net", + "adac" to "whois.nic.adac", + "ads" to "domain-registry-whois.l.google.com", + "adult" to "whois.afilias-srs.net", + "ae" to "whois-check.aeda.net.ae", + "aeg" to "whois.nic.aeg", + "aero" to "whois.aero", + "af" to "whois.nic.af", + "afamilycompany" to "whois.nic.afamilycompany", + "afl" to "whois.nic.afl", + "ag" to "whois.nic.ag", + "agakhan" to "whois.afilias-srs.net", + "agency" to "whois.donuts.co", + "ai" to "whois.ai", + "aigo" to "whois.afilias-srs.net", + "airbus" to "whois.nic.airbus", + "airforce" to "whois.unitedtld.com", + "airtel" to "whois.nic.airtel", + "akdn" to "whois.afilias-srs.net", + "alfaromeo" to "whois.afilias-srs.net", + "alibaba" to "whois.afilias-srs.net", + "alipay" to "whois.afilias-srs.net", + "allfinanz" to "whois.ksregistry.net", + "allstate" to "whois.afilias-srs.net", + "ally" to "whois.afilias-srs.net", + "alsace" to "whois-alsace.nic.fr", + "alstom" to "whois.nic.alstom", + "am" to "whois.amnic.net", + "americanfamily" to "whois.nic.americanfamily", + "amfam" to "whois.nic.amfam", + "amsterdam" to "whois.nic.amsterdam", + "android" to "domain-registry-whois.l.google.com", + "anquan" to "whois.teleinfo.cn", + "anz" to "whois.nic.anz", + "aol" to "whois.nic.aol", + "apartments" to "whois.donuts.co", + "app" to "whois.nic.google", + "apple" to "whois.afilias-srs.net", + "aquarelle" to "whois-aquarelle.nic.fr", + "archi" to "whois.ksregistry.net", + "army" to "whois.rightside.co", + "arpa" to "whois.iana.org", + "e164.arpa" to "whois.ripe.net", + "art" to "whois.centralnic.com", + "arte" to "whois.nic.arte", + "as" to "whois.nic.as", + "asda" to "whois.nic.asda", + "asia" to "whois.nic.asia", + "associates" to "whois.donuts.co", + "at" to "whois.nic.at", + "ac.at" to "whois.nic.at", + "co.at" to "whois.nic.at", + "gv.at" to "whois.nic.at", + "or.at" to "whois.nic.at", + "priv.at" to "whois.nic.priv.at", + "attorney" to "whois.rightside.co", + "au" to "whois.audns.net.au", + "auction" to "whois.donuts.co", + "audi" to "whois.afilias-srs.net", + "audio" to "whois.uniregistry.net", + "auspost" to "whois.nic.auspost", + "auto" to "whois.uniregistry.net", + "autos" to "whois.afilias-srs.net", + "avianca" to "whois.afilias-srs.net", + "aw" to "whois.nic.aw", + "baidu" to "whois.ngtld.cn", + "band" to "whois.rightside.co", + "bank" to "whois.nic.bank", + "bar" to "whois.nic.bar", + "barcelona" to "whois.nic.barcelona", + "barclaycard" to "whois.nic.barclaycard", + "barclays" to "whois.nic.barclays", + "barefoot" to "whois.nic.barefoot", + "bargains" to "whois.donuts.co", + "basketball" to "whois.nic.basketball", + "bauhaus" to "whois.nic.bauhaus", + "bayern" to "whois-dub.mm-registry.com", + "bbc" to "whois.nic.bbc", + "bbt" to "whois.nic.bbt", + "bbva" to "whois.nic.bbva", + "bcg" to "whois.afilias-srs.net", + "bcn" to "whois.nic.bcn", + "be" to "whois.dns.be", + "ac.be" to "whois.dns.be", + "beats" to "whois.afilias-srs.net", + "beauty" to "whois.nic.beauty", + "beer" to "whois-dub.mm-registry.com", + "bentley" to "whois.nic.bentley", + "berlin" to "whois.nic.berlin", + "best" to "whois.nic.best", + "bestbuy" to "whois.afilias-srs.net", + "bet" to "whois.afilias.net", + "bg" to "whois.register.bg", + "bi" to "whois1.nic.bi", + "bid" to "whois.nic.bid", + "bike" to "whois.donuts.co", + "bingo" to "whois.donuts.co", + "bio" to "whois.ksregistry.net", + "biz" to "whois.biz", + "bj" to "whois.nic.bj", + "black" to "whois.afilias.net", + "blackfriday" to "whois.uniregistry.net", + "blanco" to "whois.nic.blanco", + "blockbuster" to "whois.afilias-srs.net", + "blog" to "whois.nic.blog", + "blue" to "whois.afilias.net", + "bms" to "whois.nic.bms", + "bmw" to "whois.ksregistry.net", + "bn" to "whois.bnnic.bn", + "bnl" to "whois.afilias-srs.net", + "bnpparibas" to "whois.afilias-srs.net", + "bo" to "whois.nic.bo", + "boats" to "whois.afilias-srs.net", + "boehringer" to "whois.afilias-srs.net", + "bofa" to "whois.nic.bofa", + "bom" to "whois.gtlds.nic.br", + "bond" to "whois.nic.bond", + "boo" to "domain-registry-whois.l.google.com", + "boots" to "whois.nic.boots", + "bosch" to "whois.nic.bosch", + "bostik" to "whois-bostik.nic.fr", + "boutique" to "whois.donuts.co", + "br" to "whois.nic.br", + "adm.br" to "whois.nic.br", + "adv.br" to "whois.nic.br", + "am.br" to "whois.nic.br", + "arq.br" to "whois.nic.br", + "art.br" to "whois.nic.br", + "bio.br" to "whois.nic.br", + "cng.br" to "whois.nic.br", + "cnt.br" to "whois.nic.br", + "com.br" to "whois.nic.br", + "ecn.br" to "whois.nic.br", + "eng.br" to "whois.nic.br", + "esp.br" to "whois.nic.br", + "etc.br" to "whois.nic.br", + "eti.br" to "whois.nic.br", + "fm.br" to "whois.nic.br", + "fot.br" to "whois.nic.br", + "fst.br" to "whois.nic.br", + "g12.br" to "whois.nic.br", + "gov.br" to "whois.nic.br", + "ind.br" to "whois.nic.br", + "inf.br" to "whois.nic.br", + "jor.br" to "whois.nic.br", + "lel.br" to "whois.nic.br", + "med.br" to "whois.nic.br", + "mil.br" to "whois.nic.br", + "net.br" to "whois.nic.br", + "nom.br" to "whois.nic.br", + "ntr.br" to "whois.nic.br", + "odo.br" to "whois.nic.br", + "org.br" to "whois.nic.br", + "ppg.br" to "whois.nic.br", + "pro.br" to "whois.nic.br", + "psc.br" to "whois.nic.br", + "psi.br" to "whois.nic.br", + "rec.br" to "whois.nic.br", + "slg.br" to "whois.nic.br", + "tmp.br" to "whois.nic.br", + "tur.br" to "whois.nic.br", + "tv.br" to "whois.nic.br", + "vet.br" to "whois.nic.br", + "zlg.br" to "whois.nic.br", + "bradesco" to "whois-cl01.mm-registry.com", + "bridgestone" to "whois.nic.bridgestone", + "broadway" to "whois-cl01.mm-registry.com", + "broker" to "whois.nic.broker", + "brother" to "whois.nic.brother", + "brussels" to "whois.nic.brussels", + "budapest" to "whois-dub.mm-registry.com", + "bugatti" to "whois.afilias-srs.net", + "build" to "whois.nic.build", + "builders" to "whois.donuts.co", + "business" to "whois.donuts.co", + "buy" to "whois.afilias-srs.net", + "buzz" to "whois.nic.buzz", + "bw" to "whois.nic.net.bw", + "by" to "whois.cctld.by", + "bz" to "whois.afilias-grs.info", + "za.bz" to "whois.centralnic.com", + "bzh" to "whois-bzh.nic.fr", + "ca" to "whois.cira.ca", + "co.ca" to "whois.co.ca", + "cab" to "whois.donuts.co", + "cafe" to "whois.donuts.co", + "cal" to "domain-registry-whois.l.google.com", + "cam" to "whois.ksregistry.net", + "camera" to "whois.donuts.co", + "camp" to "whois.donuts.co", + "cancerresearch" to "whois.nic.cancerresearch", + "canon" to "whois.nic.canon", + "capetown" to "capetown-whois.registry.net.za", + "capital" to "whois.donuts.co", + "capitalone" to "whois.nic.capitalone", + "car" to "whois.uniregistry.net", + "cards" to "whois.donuts.co", + "care" to "whois.donuts.co", + "career" to "whois.nic.career", + "careers" to "whois.donuts.co", + "cars" to "whois.uniregistry.net", + "casa" to "whois-dub.mm-registry.com", + "case" to "whois.afilias-srs.net", + "caseih" to "whois.afilias-srs.net", + "cash" to "whois.donuts.co", + "casino" to "whois.donuts.co", + "cat" to "whois.cat", + "catering" to "whois.donuts.co", + "cba" to "whois.nic.cba", + "cbs" to "whois.afilias-srs.net", + "cc" to "ccwhois.verisign-grs.com", + "cd" to "whois.nic.cd", + "ceb" to "whois.afilias-srs.net", + "center" to "whois.donuts.co", + "ceo" to "whois.nic.ceo", + "cern" to "whois.afilias-srs.net", + "cf" to "whois.dot.cf", + "cfa" to "whois.nic.cfa", + "cfd" to "whois.nic.cfd", + "ch" to "whois.nic.ch", + "chanel" to "whois.nic.chanel", + "channel" to "domain-registry-whois.l.google.com", + "chat" to "whois.donuts.co", + "cheap" to "whois.donuts.co", + "chintai" to "whois.nic.chintai", + "christmas" to "whois.uniregistry.net", + "chrome" to "domain-registry-whois.l.google.com", + "chrysler" to "whois.afilias-srs.net", + "church" to "whois.donuts.co", + "ci" to "whois.nic.ci", + "cipriani" to "whois.afilias-srs.net", + "city" to "whois.donuts.co", + "cityeats" to "whois.nic.cityeats", + "cl" to "whois.nic.cl", + "claims" to "whois.donuts.co", + "cleaning" to "whois.donuts.co", + "click" to "whois.uniregistry.net", + "clinic" to "whois.donuts.co", + "clinique" to "whois.afilias-srs.net", + "clothing" to "whois.donuts.co", + "cloud" to "whois.nic.cloud", + "club" to "whois.nic.club", + "clubmed" to "whois.nic.clubmed", + "cm" to "whois.netcom.cm", + "cn" to "whois.cnnic.cn", + "ac.cn" to "whois.cnnic.net.cn", + "ah.cn" to "whois.cnnic.net.cn", + "bj.cn" to "whois.cnnic.net.cn", + "com.cn" to "whois.cnnic.net.cn", + "cq.cn" to "whois.cnnic.net.cn", + "edu.cn" to "whois.cnnic.net.cn", + "gd.cn" to "whois.cnnic.net.cn", + "gov.cn" to "whois.cnnic.net.cn", + "gs.cn" to "whois.cnnic.net.cn", + "gx.cn" to "whois.cnnic.net.cn", + "gz.cn" to "whois.cnnic.net.cn", + "hb.cn" to "whois.cnnic.net.cn", + "he.cn" to "whois.cnnic.net.cn", + "hi.cn" to "whois.cnnic.net.cn", + "hk.cn" to "whois.cnnic.net.cn", + "hl.cn" to "whois.cnnic.net.cn", + "hn.cn" to "whois.cnnic.net.cn", + "jl.cn" to "whois.cnnic.net.cn", + "js.cn" to "whois.cnnic.net.cn", + "ln.cn" to "whois.cnnic.net.cn", + "mo.cn" to "whois.cnnic.net.cn", + "net.cn" to "whois.cnnic.net.cn", + "nm.cn" to "whois.cnnic.net.cn", + "nx.cn" to "whois.cnnic.net.cn", + "org.cn" to "whois.cnnic.net.cn", + "qh.cn" to "whois.cnnic.net.cn", + "sc.cn" to "whois.cnnic.net.cn", + "sh.cn" to "whois.cnnic.net.cn", + "sn.cn" to "whois.cnnic.net.cn", + "tj.cn" to "whois.cnnic.net.cn", + "tw.cn" to "whois.cnnic.net.cn", + "xj.cn" to "whois.cnnic.net.cn", + "xz.cn" to "whois.cnnic.net.cn", + "yn.cn" to "whois.cnnic.net.cn", + "zj.cn" to "whois.cnnic.net.cn", + "co" to "whois.nic.co", + "com.co" to "whois.nic.co", + "net.co" to "whois.nic.co", + "nom.co" to "whois.nic.co", + "uk.co" to "whois.uk.co", + "coach" to "whois.donuts.co", + "codes" to "whois.donuts.co", + "coffee" to "whois.donuts.co", + "college" to "whois.centralnic.com", + "cologne" to "whois-fe1.pdt.cologne.tango.knipp.de", + "com" to "whois.crsnic.net", + "ae.com" to "whois.centralnic.net", + "africa.com" to "srs-whois.dns.net.za", + "ar.com" to "whois.centralnic.com", + "br.com" to "whois.centralnic.com", + "cn.com" to "whois.centralnic.com", + "co.com" to "whois.centralnic.net", + "de.com" to "whois.centralnic.com", + "eu.com" to "whois.centralnic.com", + "gb.com" to "whois.centralnic.com", + "gr.com" to "whois.centralnic.com", + "hu.com" to "whois.centralnic.com", + "jpn.com" to "whois.centralnic.com", + "kr.com" to "whois.centralnic.com", + "lovesnaija.com" to "srs-whois.dns.net.za", + "no.com" to "whois.centralnic.com", + "qc.com" to "whois.centralnic.com", + "ru.com" to "whois.centralnic.com", + "sa.com" to "whois.centralnic.com", + "se.com" to "whois.centralnic.com", + "uk.com" to "whois.centralnic.com", + "us.com" to "whois.centralnic.com", + "uy.com" to "whois.centralnic.com", + "web.com" to "whois.centralnic.net", + "za.com" to "whois.centralnic.com", + "comcast" to "whois.nic.comcast", + "commbank" to "whois.nic.commbank", + "community" to "whois.donuts.co", + "company" to "whois.donuts.co", + "compare" to "whois.nic.compare", + "computer" to "whois.donuts.co", + "comsec" to "whois.nic.comsec", + "condos" to "whois.donuts.co", + "construction" to "whois.donuts.co", + "consulting" to "whois.unitedtld.com", + "contact" to "whois.nic.contact", + "contractors" to "whois.donuts.co", + "cooking" to "whois-dub.mm-registry.com", + "cookingchannel" to "whois.nic.cookingchannel", + "cool" to "whois.donuts.co", + "coop" to "whois.nic.coop", + "corsica" to "whois-corsica.nic.fr", + "country" to "whois-dub.mm-registry.com", + "coupons" to "whois.donuts.co", + "courses" to "whois.aridnrs.net.au", + "cr" to "whois.nic.cr", + "credit" to "whois.donuts.co", + "creditcard" to "whois.donuts.co", + "creditunion" to "whois.afilias-srs.net", + "cricket" to "whois.nic.cricket", + "cruises" to "whois.donuts.co", + "csc" to "whois.nic.csc", + "cuisinella" to "whois.nic.cuisinella", + "cx" to "whois.nic.cx", + "cymru" to "whois.nic.cymru", + "cyou" to "whois.afilias-srs.net", + "cz" to "whois.nic.cz", + "dabur" to "whois.afilias-srs.net", + "dad" to "domain-registry-whois.l.google.com", + "dance" to "whois.unitedtld.com", + "date" to "whois.nic.date", + "dating" to "whois.donuts.co", + "datsun" to "whois.nic.gmo", + "day" to "domain-registry-whois.l.google.com", + "dclk" to "domain-registry-whois.l.google.com", + "dds" to "whois-dub.mm-registry.com", + "de" to "whois.denic.de", + "com.de" to "whois.centralnic.com", + "deals" to "whois.donuts.co", + "degree" to "whois.rightside.co", + "delivery" to "whois.donuts.co", + "deloitte" to "whois.nic.deloitte", + "delta" to "whois.afilias-srs.net", + "democrat" to "whois.unitedtld.com", + "dental" to "whois.donuts.co", + "dentist" to "whois.rightside.co", + "desi" to "whois.ksregistry.net", + "design" to "whois.nic.design", + "dev" to "domain-registry-whois.l.google.com", + "diamonds" to "whois.donuts.co", + "diet" to "whois.uniregistry.net", + "digital" to "whois.donuts.co", + "direct" to "whois.donuts.co", + "directory" to "whois.donuts.co", + "discount" to "whois.donuts.co", + "dish" to "whois.afilias-srs.net", + "diy" to "whois.nic.diy", + "dk" to "whois.dk-hostmaster.dk", + "dm" to "whois.nic.dm", + "docs" to "domain-registry-whois.l.google.com", + "doctor" to "whois.donuts.co", + "dodge" to "whois.afilias-srs.net", + "dog" to "whois.donuts.co", + "doha" to "whois.nic.doha", + "domains" to "whois.donuts.co", + "doosan" to "whois.nic.xn--cg4bki", + "dot" to "whois.afilias-srs.net", + "download" to "whois.nic.download", + "drive" to "whois.nic.google", + "dtv" to "whois.afilias-srs.net", + "dubai" to "whois.nic.dubai", + "duck" to "whois.nic.duck", + "dunlop" to "whois.afilias-srs.net", + "durban" to "durban-whois.registry.net.za", + "dvag" to "whois.ksregistry.net", + "dvr" to "whois.afilias-srs.net", + "dz" to "whois.nic.dz", + "eat" to "domain-registry-whois.l.google.com", + "ec" to "whois.nic.ec", + "com.ec" to "whois.lac.net", + "fin.ec" to "whois.lac.net", + "gov.ec" to "whois.lac.net", + "med.ec" to "whois.lac.net", + "mil.ec" to "whois.lac.net", + "net.ec" to "whois.lac.net", + "org.ec" to "whois.lac.net", + "eco" to "whois.afilias-srs.net", + "edeka" to "whois.afilias-srs.net", + "edu" to "whois.educause.edu", + "education" to "whois.donuts.co", + "ee" to "whois.eenet.ee", + "email" to "whois.donuts.co", + "emerck" to "whois.afilias-srs.net", + "energy" to "whois.donuts.co", + "engineer" to "whois.rightside.co", + "engineering" to "whois.donuts.co", + "enterprises" to "whois.donuts.co", + "epson" to "whois.aridnrs.net.au", + "equipment" to "whois.donuts.co", + "ericsson" to "whois.nic.ericsson", + "erni" to "whois.nic.erni", + "es" to "whois.nic.es", + "esq" to "domain-registry-whois.l.google.com", + "estate" to "whois.donuts.co", + "esurance" to "whois.afilias-srs.net", + "eu" to "whois.eu", + "eurovision" to "whois.nic.eurovision", + "eus" to "whois.eus.coreregistry.net", + "events" to "whois.donuts.co", + "everbank" to "whois.nic.everbank", + "exchange" to "whois.donuts.co", + "expert" to "whois.donuts.co", + "exposed" to "whois.donuts.co", + "express" to "whois.donuts.co", + "extraspace" to "whois.afilias-srs.net", + "fage" to "whois.afilias-srs.net", + "fail" to "whois.donuts.co", + "fairwinds" to "whois.nic.fairwinds", + "faith" to "whois.nic.faith", + "family" to "whois.rightside.co", + "fan" to "whois.nic.fan", + "fans" to "whois.nic.fans", + "farm" to "whois.donuts.co", + "fashion" to "whois-dub.mm-registry.com", + "fedex" to "whois.afilias-srs.net", + "feedback" to "whois.centralnic.com", + "ferrari" to "whois.afilias-srs.net", + "fi" to "whois.fi", + "fiat" to "whois.afilias-srs.net", + "fidelity" to "whois.nic.fidelity", + "fido" to "whois.afilias-srs.net", + "film" to "whois.nic.film", + "final" to "whois.gtlds.nic.br", + "finance" to "whois.donuts.co", + "financial" to "whois.donuts.co", + "firestone" to "whois.nic.firestone", + "firmdale" to "whois.nic.firmdale", + "fish" to "whois.donuts.co", + "fishing" to "whois-dub.mm-registry.com", + "fit" to "whois-dub.mm-registry.com", + "fitness" to "whois.donuts.co", + "fj" to "whois.usp.ac.fj", + "flights" to "whois.donuts.co", + "florist" to "whois.donuts.co", + "flowers" to "whois.uniregistry.net", + "flsmidth" to "whois.ksregistry.net", + "fly" to "domain-registry-whois.l.google.com", + "fm" to "whois.nic.fm", + "fo" to "whois.nic.fo", + "foo" to "domain-registry-whois.l.google.com", + "foodnetwork" to "whois.nic.foodnetwork", + "football" to "whois.donuts.co", + "forex" to "whois.nic.forex", + "forsale" to "whois.unitedtld.com", + "forum" to "whois.nic.forum", + "foundation" to "whois.donuts.co", + "fr" to "whois.nic.fr", + "aeroport.fr" to "whois.smallregistry.net", + "asso.fr" to "whois.nic.fr", + "avocat.fr" to "whois.smallregistry.net", + "chambagri.fr" to "whois.smallregistry.net", + "chirurgiens-dentistes.fr" to "whois.smallregistry.net", + "com.fr" to "whois.nic.fr", + "experts-comptables.fr" to "whois.smallregistry.net", + "geometre-expert.fr" to "whois.smallregistry.net", + "medecin.fr" to "whois.smallregistry.net", + "notaires.fr" to "whois.smallregistry.net", + "pharmacien.fr" to "whois.smallregistry.net", + "port.fr" to "whois.smallregistry.net", + "presse.fr" to "whois.nic.fr", + "tm.fr" to "whois.nic.fr", + "veterinaire.fr" to "whois.smallregistry.net", + "fresenius" to "whois.ksregistry.net", + "frl" to "whois.nic.frl", + "frogans" to "whois-frogans.nic.fr", + "frontdoor" to "whois.nic.frontdoor", + "fujitsu" to "whois.nic.gmo", + "fujixerox" to "whois.nic.fujixerox", + "fund" to "whois.donuts.co", + "furniture" to "whois.donuts.co", + "futbol" to "whois.unitedtld.com", + "fyi" to "whois.donuts.co", + "gal" to "whois.gal.coreregistry.net", + "gallery" to "whois.donuts.co", + "gallo" to "whois.nic.gallo", + "gallup" to "whois.afilias-srs.net", + "game" to "whois.uniregistry.net", + "games" to "whois.rightside.co", + "garden" to "whois-dub.mm-registry.com", + "gbiz" to "domain-registry-whois.l.google.com", + "gd" to "whois.nic.gd", + "gdn" to "whois.nic.gdn", + "gea" to "whois.afilias-srs.net", + "gent" to "whois.nic.gent", + "genting" to "whois.nic.genting", + "george" to "whois.nic.george", + "gg" to "whois.channelisles.net", + "ggee" to "whois.nic.ggee", + "gi" to "whois.afilias-grs.info", + "gift" to "whois.uniregistry.net", + "gifts" to "whois.donuts.co", + "gives" to "whois.rightside.co", + "giving" to "whois.nic.giving", + "gl" to "whois.nic.gl", + "glade" to "whois.nic.glade", + "glass" to "whois.donuts.co", + "gle" to "domain-registry-whois.l.google.com", + "global" to "whois.afilias-srs.net", + "globo" to "whois.gtlds.nic.br", + "gmail" to "domain-registry-whois.l.google.com", + "gmbh" to "whois.donuts.co", + "gmx" to "whois-fe1.gmx.tango.knipp.de", + "godaddy" to "whois.afilias-srs.net", + "gold" to "whois.donuts.co", + "goldpoint" to "whois.nic.goldpoint", + "golf" to "whois.donuts.co", + "goo" to "whois.nic.gmo", + "goodhands" to "whois.afilias-srs.net", + "goodyear" to "whois.afilias-srs.net", + "goog" to "domain-registry-whois.l.google.com", + "google" to "domain-registry-whois.l.google.com", + "gop" to "whois-cl01.mm-registry.com", + "gov" to "whois.dotgov.gov", + "gq" to "whois.dominio.gq", + "graphics" to "whois.donuts.co", + "gratis" to "whois.donuts.co", + "green" to "whois.afilias.net", + "gripe" to "whois.donuts.co", + "group" to "whois.donuts.co", + "gs" to "whois.adamsnames.tc", + "guge" to "domain-registry-whois.l.google.com", + "guide" to "whois.donuts.co", + "guitars" to "whois.uniregistry.net", + "guru" to "whois.donuts.co", + "gy" to "whois.registry.gy", + "hamburg" to "whois.nic.hamburg", + "hangout" to "domain-registry-whois.l.google.com", + "haus" to "whois.unitedtld.com", + "hdfc" to "whois.afilias-srs.net", + "hdfcbank" to "whois.afilias-srs.net", + "healthcare" to "whois.donuts.co", + "help" to "whois.uniregistry.net", + "helsinki" to "whois.afilias-srs.net", + "here" to "domain-registry-whois.l.google.com", + "hermes" to "whois.afilias-srs.net", + "hgtv" to "whois.nic.hgtv", + "hiphop" to "whois.uniregistry.net", + "hisamitsu" to "whois.nic.gmo", + "hitachi" to "whois.nic.gmo", + "hiv" to "whois.afilias-srs.net", + "hk" to "whois.hkirc.hk", + "com.hk" to "whois.hkdnr.net.hk", + "edu.hk" to "whois.hkdnr.net.hk", + "net.hk" to "whois.hkdnr.net.hk", + "org.hk" to "whois.hkdnr.net.hk", + "hkt" to "whois.afilias-srs.net", + "hm" to "whois.registry.hm", + "hn" to "whois.nic.hn", + "hockey" to "whois.donuts.co", + "holdings" to "whois.donuts.co", + "holiday" to "whois.donuts.co", + "homedepot" to "whois.afilias-srs.net", + "homes" to "whois.afilias-srs.net", + "honda" to "whois.nic.honda", + "horse" to "whois-dub.mm-registry.com", + "host" to "whois.centralnic.com", + "hosting" to "whois.uniregistry.net", + "house" to "whois.donuts.co", + "how" to "domain-registry-whois.l.google.com", + "hr" to "whois.dns.hr", + "ht" to "whois.nic.ht", + "hu" to "whois.nic.hu", + "hughes" to "whois.afilias-srs.net", + "hyundai" to "whois.nic.hyundai", + "ibm" to "whois.nic.ibm", + "icbc" to "whois.afilias-srs.net", + "ice" to "whois.nic.ice", + "icu" to "whois.nic.icu", + "id" to "whois.pandi.or.id", + "go.id" to "whois.idnic.net.id", + "mil.id" to "whois.idnic.net.id", + "ie" to "whois.domainregistry.ie", + "ifm" to "whois.nic.ifm", + "iinet" to "whois.aridnrs.net.au", + "ikano" to "whois.ikano.tld-box.at", + "il" to "whois.isoc.org.il", + "ac.il" to "whois.isoc.org.il", + "co.il" to "whois.isoc.org.il", + "gov.il" to "whois.isoc.org.il", + "k12.il" to "whois.isoc.org.il", + "muni.il" to "whois.isoc.org.il", + "net.il" to "whois.isoc.org.il", + "org.il" to "whois.isoc.org.il", + "im" to "whois.nic.im", + "imamat" to "whois.afilias-srs.net", + "immo" to "whois.donuts.co", + "immobilien" to "whois.unitedtld.com", + "in" to "whois.inregistry.in", + "ac.in" to "whois.inregistry.in", + "co.in" to "whois.inregistry.in", + "ernet.in" to "whois.inregistry.in", + "gov.in" to "whois.inregistry.in", + "net.in" to "whois.inregistry.in", + "org.in" to "whois.inregistry.in", + "res.in" to "whois.inregistry.in", + "industries" to "whois.donuts.co", + "infiniti" to "whois.nic.gmo", + "info" to "whois.afilias.info", + "ing" to "domain-registry-whois.l.google.com", + "ink" to "whois.centralnic.com", + "institute" to "whois.donuts.co", + "insurance" to "whois.nic.insurance", + "insure" to "whois.donuts.co", + "int" to "whois.iana.org", + "international" to "whois.donuts.co", + "investments" to "whois.donuts.co", + "io" to "whois.nic.io", + "ir" to "whois.nic.ir", + "irish" to "whois.afilias-srs.net", + "is" to "whois.isnic.is", + "iselect" to "whois.nic.iselect", + "ismaili" to "whois.afilias-srs.net", + "ist" to "whois.afilias-srs.net", + "istanbul" to "whois.afilias-srs.net", + "it" to "whois.nic.it", + "itv" to "whois.afilias-srs.net", + "iveco" to "whois.afilias-srs.net", + "jaguar" to "whois.nic.jaguar", + "java" to "whois.nic.java", + "jcb" to "whois.nic.gmo", + "jcp" to "whois.afilias-srs.net", + "je" to "whois.channelisles.net", + "jeep" to "whois.afilias-srs.net", + "jetzt" to "whois.nic.jetzt", + "jewelry" to "whois.donuts.co", + "jll" to "whois.afilias-srs.net", + "jobs" to "whois.nic.jobs", + "joburg" to "joburg-whois.registry.net.za", + "jp" to "whois.jprs.jp", + "ac.jp" to "whois.jprs.jp", + "juegos" to "whois.uniregistry.net", + "juniper" to "whois.nic.juniper", + "kaufen" to "whois.unitedtld.com", + "kddi" to "whois.nic.kddi", + "ke" to "whois.kenic.or.ke", + "ac.ke" to "whois.kenic.or.ke", + "co.ke" to "whois.kenic.or.ke", + "kerryhotels" to "whois.nic.kerryhotels", + "kerrylogistics" to "whois.nic.kerrylogistics", + "kerryproperties" to "whois.nic.kerryproperties", + "kfh" to "whois.nic.kfh", + "ki" to "whois.nic.ki", + "kia" to "whois.nic.kia", + "kim" to "whois.afilias.net", + "kitchen" to "whois.donuts.co", + "kiwi" to "whois.dot-kiwi.com", + "kn" to "whois.nic.kn", + "koeln" to "whois-fe1.pdt.koeln.tango.knipp.de", + "komatsu" to "whois.nic.komatsu", + "kosher" to "whois.afilias-srs.net", + "kr" to "whois.kr", + "ac.kr" to "whois.nic.or.kr", + "co.kr" to "whois.nic.or.kr", + "go.kr" to "whois.nic.or.kr", + "ne.kr" to "whois.nic.or.kr", + "nm.kr" to "whois.nic.or.kr", + "or.kr" to "whois.nic.or.kr", + "re.kr" to "whois.nic.or.kr", + "krd" to "whois.aridnrs.net.au", + "kred" to "whois.nic.kred", + "kuokgroup" to "whois.nic.kuokgroup", + "ky" to "whois.kyregistry.ky", + "kyoto" to "whois.nic.kyoto", + "kz" to "whois.nic.kz", + "la" to "whois.nic.la", + "lacaixa" to "whois.nic.lacaixa", + "ladbrokes" to "whois.nic.ladbrokes", + "lamborghini" to "whois.afilias-srs.net", + "lamer" to "whois.afilias-srs.net", + "lancaster" to "whois-lancaster.nic.fr", + "lancia" to "whois.afilias-srs.net", + "lancome" to "whois.nic.lancome", + "land" to "whois.donuts.co", + "landrover" to "whois.nic.landrover", + "lasalle" to "whois.afilias-srs.net", + "lat" to "whois.nic.lat", + "latino" to "whois.afilias-srs.net", + "latrobe" to "whois.nic.latrobe", + "law" to "whois-dub.mm-registry.com", + "lawyer" to "whois.rightside.co", + "lc" to "whois.afilias-grs.info", + "lds" to "whois.afilias-srs.net", + "lease" to "whois.donuts.co", + "leclerc" to "whois-leclerc.nic.fr", + "lefrak" to "whois.nic.lefrak", + "legal" to "whois.donuts.co", + "lego" to "whois.nic.lego", + "lexus" to "whois.nic.lexus", + "lgbt" to "whois.afilias.net", + "li" to "whois.nic.li", + "liaison" to "whois.nic.liaison", + "lidl" to "whois.nic.lidl", + "life" to "whois.donuts.co", + "lifestyle" to "whois.nic.lifestyle", + "lighting" to "whois.donuts.co", + "limited" to "whois.donuts.co", + "limo" to "whois.donuts.co", + "linde" to "whois.nic.linde", + "link" to "whois.uniregistry.net", + "lipsy" to "whois.nic.lipsy", + "live" to "whois.rightside.co", + "lixil" to "whois.nic.lixil", + "loan" to "whois.nic.loan", + "loans" to "whois.donuts.co", + "locker" to "whois.afilias-srs.net", + "locus" to "whois.nic.locus", + "lol" to "whois.uniregistry.net", + "london" to "whois-lon.mm-registry.com", + "lotte" to "whois.nic.lotte", + "lotto" to "whois.afilias.net", + "love" to "whois.centralnic.com", + "lpl" to "whois.nic.lpl", + "lplfinancial" to "whois.nic.lplfinancial", + "lt" to "whois.domreg.lt", + "ltd" to "whois.donuts.co", + "ltda" to "whois.afilias-srs.net", + "lu" to "whois.dns.lu", + "lundbeck" to "whois.nic.lundbeck", + "luxe" to "whois-dub.mm-registry.com", + "luxury" to "whois.donuts.co", + "lv" to "whois.nic.lv", + "eu.lv" to "whois.biz", + "ly" to "whois.nic.ly", + "ma" to "whois.iam.net.ma", + "macys" to "whois.nic.macys", + "madrid" to "whois.madrid.rs.corenic.net", + "maison" to "whois.donuts.co", + "makeup" to "whois.nic.makeup", + "man" to "whois.nic.man", + "management" to "whois.donuts.co", + "mango" to "whois.mango.coreregistry.net", + "market" to "whois.rightside.co", + "marketing" to "whois.donuts.co", + "markets" to "whois.nic.markets", + "marriott" to "whois.afilias-srs.net", + "maserati" to "whois.afilias-srs.net", + "mba" to "whois.donuts.co", + "mc" to "whois.ripe.net", + "asso.mc" to "whois.ripe.net", + "tm.mc" to "whois.ripe.net", + "mckinsey" to "whois.afilias-srs.net", + "md" to "whois.nic.md", + "me" to "whois.nic.me", + "med" to "whois.nic.med", + "media" to "whois.donuts.co", + "meet" to "whois.afilias.net", + "melbourne" to "whois.aridnrs.net.au", + "meme" to "domain-registry-whois.l.google.com", + "memorial" to "whois.donuts.co", + "men" to "whois.nic.men", + "menu" to "whois.nic.menu", + "metlife" to "whois.afilias-srs.net", + "mg" to "whois.nic.mg", + "miami" to "whois-dub.mm-registry.com", + "mil" to "whois.internic.net", + "mini" to "whois.ksregistry.net", + "mit" to "whois.afilias-srs.net", + "mitsubishi" to "whois.nic.gmo", + "mk" to "whois.marnet.mk", + "ml" to "whois.dot.ml", + "mls" to "whois.nic.mls", + "mm" to "whois.nic.mm", + "com.mm" to "whois.nic.mm", + "edu.mm" to "whois.nic.mm", + "gov.mm" to "whois.nic.mm", + "net.mm" to "whois.nic.mm", + "org.mm" to "whois.nic.mm", + "mma" to "whois-mma.nic.fr", + "mn" to "whois.afilias-grs.info", + "mo" to "whois.monic.mo", + "mobi" to "whois.afilias.net", + "moda" to "whois.unitedtld.com", + "moe" to "whois.nic.moe", + "mom" to "whois.uniregistry.net", + "monash" to "whois.nic.monash", + "money" to "whois.donuts.co", + "monster" to "whois.afilias-srs.net", + "mopar" to "whois.afilias-srs.net", + "mormon" to "whois.afilias-srs.net", + "mortgage" to "whois.rightside.co", + "moscow" to "whois.nic.moscow", + "motorcycles" to "whois.afilias-srs.net", + "mov" to "domain-registry-whois.l.google.com", + "movie" to "whois.donuts.co", + "movistar" to "whois-fe.movistar.tango.knipp.de", + "ms" to "whois.adamsnames.tc", + "mtn" to "whois.nic.mtn", + "mtpc" to "whois.nic.gmo", + "mtr" to "whois.nic.mtr", + "mu" to "whois.nic.mu", + "museum" to "whois.museum", + "mutuelle" to "whois-mutuelle.nic.fr", + "mx" to "whois.mx", + "com.mx" to "whois.nic.mx", + "edu.mx" to "whois.nic.mx", + "gob.mx" to "whois.nic.mx", + "gov.mx" to "whois.nic.mx", + "net.mx" to "whois.nic.mx", + "org.mx" to "whois.nic.mx", + "my" to "whois.mynic.net.my", + "com.my" to "whois.mynic.net.my", + "edu.my" to "whois.mynic.net.my", + "gov.my" to "whois.mynic.net.my", + "net.my" to "whois.mynic.net.my", + "org.my" to "whois.mynic.net.my", + "mz" to "whois.nic.mz", + "na" to "whois.na-nic.com.na", + "nab" to "whois.nic.nab", + "nadex" to "whois.nic.nadex", + "nagoya" to "whois.gmoregistry.net", + "name" to "whois.nic.name", + "nationwide" to "whois.nic.nationwide", + "natura" to "whois.afilias-srs.net", + "navy" to "whois.rightside.co", + "nc" to "whois.nc", + "nec" to "whois.nic.nec", + "net" to "whois.crsnic.net", + "gb.net" to "whois.centralnic.com", + "hu.net" to "whois.centralnic.com", + "in.net" to "whois.centralnic.com", + "jp.net" to "whois.centralnic.com", + "se.net" to "whois.centralnic.com", + "uk.net" to "whois.centralnic.com", + "za.net" to "whois.za.net", + "netbank" to "whois.nic.netbank", + "network" to "whois.donuts.co", + "new" to "domain-registry-whois.l.google.com", + "newholland" to "whois.afilias-srs.net", + "news" to "whois.rightside.co", + "next" to "whois.nic.next", + "nextdirect" to "whois.nic.nextdirect", + "nexus" to "domain-registry-whois.l.google.com", + "nf" to "whois.nic.nf", + "ng" to "whois.nic.net.ng", + "ngo" to "whois.publicinterestregistry.net", + "nico" to "whois.nic.nico", + "nikon" to "whois.nic.nikon", + "ninja" to "whois.donuts.co", + "nissan" to "whois.nic.gmo", + "nissay" to "whois.nic.nissay", + "nl" to "whois.domain-registry.nl", + "no" to "whois.norid.no", + "nokia" to "whois.afilias-srs.net", + "norton" to "whois.nic.norton", + "nowruz" to "whois.agitsys.net", + "nowtv" to "whois.afilias-srs.net", + "nra" to "whois.afilias-srs.net", + "nrw" to "whois.nic.nrw", + "nu" to "whois.iis.nu", + "nyc" to "whois.nic.nyc", + "nz" to "whois.srs.net.nz", + "ac.nz" to "whois.srs.net.nz", + "co.nz" to "whois.srs.net.nz", + "geek.nz" to "whois.srs.net.nz", + "gen.nz" to "whois.srs.net.nz", + "iwi.nz" to "whois.srs.net.nz", + "kiwi.nz" to "whois.srs.net.nz", + "maori.nz" to "whois.srs.net.nz", + "net.nz" to "whois.srs.net.nz", + "org.nz" to "whois.srs.net.nz", + "school.nz" to "whois.srs.net.nz", + "obi" to "whois.nic.obi", + "observer" to "whois.nic.observer", + "off" to "whois.nic.off", + "olayan" to "whois.nic.olayan", + "olayangroup" to "whois.nic.olayangroup", + "ollo" to "whois.afilias-srs.net", + "om" to "whois.registry.om", + "omega" to "whois.nic.omega", + "one" to "whois.nic.one", + "ong" to "whois.publicinterestregistry.net", + "onl" to "whois.afilias-srs.net", + "online" to "whois.centralnic.com", + "onyourside" to "whois.nic.onyourside", + "ooo" to "whois.nic.ooo", + "oracle" to "whois.nic.oracle", + "orange" to "whois.nic.orange", + "org" to "whois.pir.org", + "ae.org" to "whois.centralnic.com", + "eu.org" to "whois.eu.org", + "us.org" to "whois.centralnic.com", + "za.org" to "whois.za.org", + "organic" to "whois.afilias-srs.net", + "orientexpress" to "whois.afilias-srs.net", + "origin" to "whois.afilias-srs.net", + "origins" to "whois.afilias-srs.net", + "osaka" to "whois.nic.osaka", + "ott" to "whois.afilias-srs.net", + "ovh" to "whois-ovh.nic.fr", + "page" to "domain-registry-whois.l.google.com", + "panasonic" to "whois.nic.gmo", + "paris" to "whois-paris.nic.fr", + "pars" to "whois.agitsys.net", + "partners" to "whois.donuts.co", + "parts" to "whois.donuts.co", + "party" to "whois.nic.party", + "pccw" to "whois.afilias-srs.net", + "pe" to "kero.yachay.pe", + "pet" to "whois.afilias.net", + "pf" to "whois.registry.pf", + "philips" to "whois.nic.philips", + "photo" to "whois.uniregistry.net", + "photography" to "whois.donuts.co", + "photos" to "whois.donuts.co", + "physio" to "whois.nic.physio", + "pics" to "whois.uniregistry.net", + "pictures" to "whois.donuts.co", + "pid" to "whois.nic.pid", + "pink" to "whois.afilias.net", + "pioneer" to "whois.nic.gmo", + "pizza" to "whois.donuts.co", + "pl" to "whois.dns.pl", + "agro.pl" to "whois.dns.pl", + "aid.pl" to "whois.dns.pl", + "atm.pl" to "whois.dns.pl", + "auto.pl" to "whois.dns.pl", + "biz.pl" to "whois.dns.pl", + "co.pl" to "whois.co.pl", + "com.pl" to "whois.dns.pl", + "edu.pl" to "whois.dns.pl", + "gmina.pl" to "whois.dns.pl", + "gsm.pl" to "whois.dns.pl", + "info.pl" to "whois.dns.pl", + "mail.pl" to "whois.dns.pl", + "media.pl" to "whois.dns.pl", + "miasta.pl" to "whois.dns.pl", + "mil.pl" to "whois.dns.pl", + "net.pl" to "whois.dns.pl", + "nom.pl" to "whois.dns.pl", + "org.pl" to "whois.dns.pl", + "pc.pl" to "whois.dns.pl", + "priv.pl" to "whois.dns.pl", + "realestate.pl" to "whois.dns.pl", + "rel.pl" to "whois.dns.pl", + "shop.pl" to "whois.dns.pl", + "sklep.pl" to "whois.dns.pl", + "sos.pl" to "whois.dns.pl", + "targi.pl" to "whois.dns.pl", + "tm.pl" to "whois.dns.pl", + "tourism.pl" to "whois.dns.pl", + "travel.pl" to "whois.dns.pl", + "turystyka.pl" to "whois.dns.pl", + "place" to "whois.donuts.co", + "play" to "whois.nic.google", + "playstation" to "whois.nic.playstation", + "plumbing" to "whois.donuts.co", + "plus" to "whois.donuts.co", + "pm" to "whois.nic.fr", + "pnc" to "whois.afilias-srs.net", + "pohl" to "whois.ksregistry.net", + "poker" to "whois.afilias.net", + "politie" to "whois.nic.politie", + "porn" to "whois.afilias-srs.net", + "post" to "whois.dotpostregistry.net", + "pr" to "whois.nic.pr", + "press" to "whois.centralnic.com", + "pro" to "whois.afilias.net", + "prod" to "domain-registry-whois.l.google.com", + "productions" to "whois.donuts.co", + "prof" to "domain-registry-whois.l.google.com", + "progressive" to "whois.afilias-srs.net", + "promo" to "whois.afilias.net", + "properties" to "whois.donuts.co", + "property" to "whois.uniregistry.net", + "protection" to "whois.centralnic.com", + "ps" to "whois.pnina.ps", + "pt" to "whois.dns.pt", + "com.pt" to "whois.dns.pt", + "edu.pt" to "whois.dns.pt", + "pub" to "whois.unitedtld.com", + "pw" to "whois.nic.pw", + "pwc" to "whois.afilias-srs.net", + "qa" to "whois.registry.qa", + "qpon" to "whois.nic.qpon", + "quebec" to "whois.nic.quebec", + "quest" to "whois.nic.quest", + "racing" to "whois.nic.racing", + "radio" to "whois.nic.radio", + "raid" to "whois.nic.raid", + "re" to "whois.nic.fr", + "realestate" to "whois.nic.realestate", + "realty" to "whois.nic.realty", + "recipes" to "whois.donuts.co", + "red" to "whois.afilias.net", + "redstone" to "whois.afilias-srs.net", + "redumbrella" to "whois.afilias-srs.net", + "rehab" to "whois.rightside.co", + "reise" to "whois.nic.reise", + "reisen" to "whois.donuts.co", + "reit" to "whois.nic.reit", + "rent" to "whois.nic.rent", + "rentals" to "whois.donuts.co", + "repair" to "whois.donuts.co", + "report" to "whois.donuts.co", + "republican" to "whois.rightside.co", + "rest" to "whois.centralnic.com", + "restaurant" to "whois.donuts.co", + "review" to "whois.nic.review", + "reviews" to "whois.unitedtld.com", + "rexroth" to "whois.nic.rexroth", + "rich" to "whois.afilias-srs.net", + "richardli" to "whois.afilias-srs.net", + "ricoh" to "whois.nic.ricoh", + "rightathome" to "whois.nic.rightathome", + "rio" to "whois.gtlds.nic.br", + "rip" to "whois.rightside.co", + "ro" to "whois.rotld.ro", + "arts.ro" to "whois.rotld.ro", + "com.ro" to "whois.rotld.ro", + "firm.ro" to "whois.rotld.ro", + "info.ro" to "whois.rotld.ro", + "nom.ro" to "whois.rotld.ro", + "nt.ro" to "whois.rotld.ro", + "org.ro" to "whois.rotld.ro", + "rec.ro" to "whois.rotld.ro", + "store.ro" to "whois.rotld.ro", + "tm.ro" to "whois.rotld.ro", + "www.ro" to "whois.rotld.ro", + "rocks" to "whois.unitedtld.com", + "rodeo" to "whois-dub.mm-registry.com", + "rogers" to "whois.afilias-srs.net", + "rs" to "whois.rnids.rs", + "co.rs" to "whois.rnids.rs", + "edu.rs" to "whois.rnids.rs", + "in.rs" to "whois.rnids.rs", + "org.rs" to "whois.rnids.rs", + "rsvp" to "domain-registry-whois.l.google.com", + "ru" to "whois.ripn.net", + "com.ru" to "whois.ripn.net", + "edu.ru" to "whois.informika.ru", + "msk.ru" to "whois.nic.ru", + "net.ru" to "whois.ripn.net", + "org.ru" to "whois.nic.ru", + "pp.ru" to "whois.nic.ru", + "spb.ru" to "whois.nic.ru", + "ruhr" to "whois.nic.ruhr", + "run" to "whois.donuts.co", + "rw" to "whois.ricta.org.rw", + "rwe" to "whois.nic.rwe", + "sa" to "whois.nic.net.sa", + "saarland" to "whois.ksregistry.net", + "sale" to "whois.rightside.co", + "salon" to "whois.donuts.co", + "samsclub" to "whois.nic.samsclub", + "samsung" to "whois.nic.samsung", + "sandvik" to "whois.nic.sandvik", + "sandvikcoromant" to "whois.nic.sandvikcoromant", + "sanofi" to "whois.nic.sanofi", + "sap" to "whois.nic.sap", + "sarl" to "whois.donuts.co", + "saxo" to "whois.aridnrs.net.au", + "sb" to "whois.nic.net.sb", + "sbi" to "whois.afilias-srs.net", + "sbs" to "whois.nic.sbs", + "sc" to "whois.afilias-grs.info", + "sca" to "whois.nic.sca", + "scb" to "whois.nic.scb", + "schaeffler" to "whois.afilias-srs.net", + "schmidt" to "whois.nic.schmidt", + "scholarships" to "whois.afilias-srs.net", + "school" to "whois.donuts.co", + "schule" to "whois.donuts.co", + "schwarz" to "whois.nic.schwarz", + "science" to "whois.nic.science", + "scjohnson" to "whois.nic.scjohnson", + "scor" to "whois.nic.scor", + "scot" to "whois.scot.coreregistry.net", + "se" to "whois.iis.se", + "com.se" to "whois.centralnic.com", + "seat" to "whois.nic.seat", + "security" to "whois.nic.security", + "seek" to "whois.nic.seek", + "select" to "whois.nic.select", + "services" to "whois.donuts.co", + "ses" to "whois.nic.ses", + "seven" to "whois.nic.seven", + "sew" to "whois.afilias-srs.net", + "sex" to "whois.afilias-srs.net", + "sexy" to "whois.uniregistry.net", + "sfr" to "whois.nic.sfr", + "sg" to "whois.nic.net.sg", + "com.sg" to "whois.nic.net.sg", + "edu.sg" to "whois.nic.net.sg", + "gov.sg" to "whois.nic.net.sg", + "net.sg" to "whois.nic.net.sg", + "org.sg" to "whois.nic.net.sg", + "sh" to "whois.nic.sh", + "shangrila" to "whois.nic.shangrila", + "sharp" to "whois.nic.gmo", + "shaw" to "whois.afilias-srs.net", + "shell" to "whois.nic.shell", + "shia" to "whois.agitsys.net", + "shiksha" to "whois.afilias.net", + "shoes" to "whois.donuts.co", + "shopping" to "whois.donuts.co", + "shouji" to "whois.teleinfo.cn", + "show" to "whois.donuts.co", + "showtime" to "whois.afilias-srs.net", + "shriram" to "whois.afilias-srs.net", + "si" to "whois.arnes.si", + "sina" to "whois.afilias-srs.net", + "singles" to "whois.donuts.co", + "site" to "whois.centralnic.com", + "sk" to "whois.sk-nic.sk", + "ski" to "whois.ksregistry.net", + "skin" to "whois.nic.skin", + "sky" to "whois.nic.sky", + "sl" to "whois.nic.sl", + "sling" to "whois.afilias-srs.net", + "sm" to "whois.nic.sm", + "smart" to "whois-gtld.smart.com.ph", + "sn" to "whois.nic.sn", + "sncf" to "whois-sncf.nic.fr", + "so" to "whois.nic.so", + "soccer" to "whois.donuts.co", + "social" to "whois.unitedtld.com", + "softbank" to "whois.nic.softbank", + "software" to "whois.rightside.co", + "sohu" to "whois.gtld.knet.cn", + "solar" to "whois.donuts.co", + "solutions" to "whois.donuts.co", + "sony" to "whois.nic.sony", + "soy" to "domain-registry-whois.l.google.com", + "space" to "whois.nic.space", + "spiegel" to "whois.ksregistry.net", + "spreadbetting" to "whois.nic.spreadbetting", + "srl" to "whois.afilias-srs.net", + "srt" to "whois.afilias-srs.net", + "st" to "whois.nic.st", + "stada" to "whois.afilias-srs.net", + "star" to "whois.afilias-srs.net", + "starhub" to "whois.nic.starhub", + "statebank" to "whois.afilias-srs.net", + "statoil" to "whois.nic.statoil", + "stc" to "whois.centralnic.com", + "stcgroup" to "whois.centralnic.com", + "stockholm" to "whois.afilias-srs.net", + "storage" to "whois.afilias-srs.net", + "store" to "whois.nic.store", + "studio" to "whois.rightside.co", + "study" to "whois.nic.study", + "style" to "whois.donuts.co", + "su" to "whois.ripn.net", + "sochi.su" to "whois.nic.ru", + "sucks" to "whois.nic.sucks", + "supplies" to "whois.donuts.co", + "supply" to "whois.donuts.co", + "support" to "whois.donuts.co", + "surf" to "whois-dub.mm-registry.com", + "surgery" to "whois.donuts.co", + "swatch" to "whois.nic.swatch", + "swiss" to "whois.nic.swiss", + "sx" to "whois.sx", + "sy" to "whois.tld.sy", + "sydney" to "whois.nic.sydney", + "symantec" to "whois.nic.symantec", + "systems" to "whois.donuts.co", + "tab" to "whois.nic.tab", + "taipei" to "whois.nic.taipei", + "tatamotors" to "whois.nic.tatamotors", + "tatar" to "whois.nic.tatar", + "tattoo" to "whois.uniregistry.net", + "tax" to "whois.donuts.co", + "taxi" to "whois.donuts.co", + "tc" to "whois.adamsnames.tc", + "tci" to "whois.agitsys.net", + "tdk" to "whois.nic.tdk", + "team" to "whois.donuts.co", + "tech" to "whois.nic.tech", + "technology" to "whois.donuts.co", + "tel" to "whois.nic.tel", + "telecity" to "whois.nic.telecity", + "telefonica" to "whois-fe.telefonica.tango.knipp.de", + "temasek" to "whois.afilias-srs.net", + "tennis" to "whois.donuts.co", + "teva" to "whois.nic.teva", + "tf" to "whois.adamsnames.tc", + "tg" to "whois.nic.tg", + "th" to "whois.thnic.co.th", + "ac.th" to "whois.thnic.net", + "co.th" to "whois.thnic.net", + "go.th" to "whois.thnic.net", + "in.th" to "whois.thnic.net", + "mi.th" to "whois.thnic.net", + "net.th" to "whois.thnic.net", + "or.th" to "whois.thnic.net", + "thd" to "whois.afilias-srs.net", + "theater" to "whois.donuts.co", + "theatre" to "whois.nic.theatre", + "tiaa" to "whois.nic.tiaa", + "tickets" to "whois.nic.tickets", + "tienda" to "whois.donuts.co", + "tiffany" to "whois.nic.tiffany", + "tiffay" to "whois.nic.tiffany", + "tips" to "whois.donuts.co", + "tires" to "whois.donuts.co", + "tirol" to "whois.nic.tirol", + "tk" to "whois.dot.tk", + "tl" to "whois.nic.tl", + "tm" to "whois.nic.tm", + "tn" to "whois.ati.tn", + "to" to "monarch.tonic.to", + "today" to "whois.donuts.co", + "tokyo" to "whois.nic.tokyo", + "tools" to "whois.donuts.co", + "top" to "whois.nic.top", + "toray" to "whois.nic.toray", + "toshiba" to "whois.nic.toshiba", + "total" to "whois-total.nic.fr", + "tours" to "whois.donuts.co", + "town" to "whois.donuts.co", + "toyota" to "whois.nic.toyota", + "toys" to "whois.donuts.co", + "tr" to "whois.nic.tr", + "bbs.tr" to "whois.metu.edu.tr", + "com.tr" to "whois.metu.edu.tr", + "edu.tr" to "whois.metu.edu.tr", + "gov.tr" to "whois.metu.edu.tr", + "k12.tr" to "whois.metu.edu.tr", + "mil.tr" to "whois.metu.edu.tr", + "net.tr" to "whois.metu.edu.tr", + "org.tr" to "whois.metu.edu.tr", + "trade" to "whois.nic.trade", + "trading" to "whois.nic.trading", + "training" to "whois.donuts.co", + "travel" to "whois.nic.travel", + "travelchannel" to "whois.nic.travelchannel", + "travelers" to "whois.afilias-srs.net", + "travelersinsurance" to "whois.afilias-srs.net", + "trust" to "whois.nic.trust", + "trv" to "whois.afilias-srs.net", + "tui" to "whois.ksregistry.net", + "tv" to "tvwhois.verisign-grs.com", + "tvs" to "whois.afilias-srs.net", + "tw" to "whois.twnic.net.tw", + "com.tw" to "whois.twnic.net", + "net.tw" to "whois.twnic.net", + "org.tw" to "whois.twnic.net", + "tz" to "whois.tznic.or.tz", + "ua" to "whois.ua", + "biz.ua" to "whois.biz.ua", + "co.ua" to "whois.co.ua", + "com.ua" to "whois.net.ua", + "dn.ua" to "whois.net.ua", + "in.ua" to "whois.in.ua", + "kh.ua" to "whois.net.ua", + "kiev.ua" to "whois.net.ua", + "lg.ua" to "whois.net.ua", + "lviv.ua" to "whois.net.ua", + "net.ua" to "whois.net.ua", + "org.ua" to "whois.net.ua", + "pp.ua" to "whois.pp.ua", + "ubank" to "whois.nic.ubank", + "ubs" to "whois.nic.ubs", + "uconnect" to "whois.afilias-srs.net", + "ug" to "whois.co.ug", + "uk" to "whois.nic.uk", + "ac.uk" to "whois.ja.net", + "co.uk" to "whois.nic.uk", + "gov.uk" to "whois.ja.net", + "ltd.uk" to "whois.nic.uk", + "me.uk" to "whois.nic.uk", + "net.uk" to "whois.nic.uk", + "org.uk" to "whois.nic.uk", + "plc.uk" to "whois.nic.uk", + "university" to "whois.donuts.co", + "uno" to "whois.nic.uno", + "uol" to "whois.gtlds.nic.br", + "ups" to "whois.afilias-srs.net", + "us" to "whois.nic.us", + "fed.us" to "whois.nic.gov", + "uy" to "whois.nic.org.uy", + "uz" to "whois.cctld.uz", + "vacations" to "whois.donuts.co", + "vana" to "whois.nic.vana", + "vanguard" to "whois.nic.vanguard", + "vc" to "whois.adamsnames.tc", + "ve" to "whois.nic.ve", + "co.ve" to "whois.nic.ve", + "com.ve" to "whois.nic.ve", + "info.ve" to "whois.nic.ve", + "net.ve" to "whois.nic.ve", + "org.ve" to "whois.nic.ve", + "web.ve" to "whois.nic.ve", + "vegas" to "whois.afilias-srs.net", + "ventures" to "whois.donuts.co", + "verisign" to "whois.nic.verisign", + "vermögensberater" to "whois.ksregistry.net", + "vermögensberatung" to "whois.ksregistry.net", + "versicherung" to "whois.nic.versicherung", + "vet" to "whois.rightside.co", + "vg" to "ccwhois.ksregistry.net", + "viajes" to "whois.donuts.co", + "video" to "whois.rightside.co", + "vig" to "whois.afilias-srs.net", + "viking" to "whois.afilias-srs.net", + "villas" to "whois.donuts.co", + "vin" to "whois.donuts.co", + "vip" to "whois-dub.mm-registry.com", + "virgin" to "whois.nic.virgin", + "visa" to "whois.nic.visa", + "vision" to "whois.donuts.co", + "vista" to "whois.nic.vista", + "vistaprint" to "whois.nic.vistaprint", + "viva" to "whois.centralnic.com", + "vlaanderen" to "whois.nic.vlaanderen", + "vodka" to "whois-dub.mm-registry.com", + "volkswagen" to "whois.afilias-srs.net", + "volvo" to "whois.nic.volvo", + "vote" to "whois.afilias.net", + "voting" to "whois.voting.tld-box.at", + "voto" to "whois.afilias.net", + "voyage" to "whois.donuts.co", + "vu" to "vunic.vu", + "wales" to "whois.nic.wales", + "walmart" to "whois.nic.walmart", + "walter" to "whois.nic.walter", + "wang" to "whois.gtld.knet.cn", + "warman" to "whois.nic.warman", + "watch" to "whois.donuts.co", + "webcam" to "whois.nic.webcam", + "weber" to "whois.nic.weber", + "website" to "whois.nic.website", + "wed" to "whois.nic.wed", + "wedding" to "whois-dub.mm-registry.com", + "weibo" to "whois.afilias-srs.net", + "wf" to "whois.nic.fr", + "whoswho" to "whois.nic.whoswho", + "wien" to "whois.nic.wien", + "wiki" to "whois.nic.wiki", + "win" to "whois.nic.win", + "wine" to "whois.donuts.co", + "wme" to "whois.centralnic.com", + "wolterskluwer" to "whois.afilias-srs.net", + "woodside" to "whois.nic.woodside", + "work" to "whois-dub.mm-registry.com", + "works" to "whois.donuts.co", + "world" to "whois.donuts.co", + "ws" to "whois.website.ws", + "wtc" to "whois.nic.wtc", + "wtf" to "whois.donuts.co", + "xerox" to "whois.nic.xerox", + "xfinity" to "whois.nic.xfinity", + "xihuan" to "whois.teleinfo.cn", + "xin" to "whois.afilias-srs.net", + "xperia" to "whois.nic.xperia", + "xxx" to "whois.nic.xxx", + "xyz" to "whois.nic.xyz", + "yachts" to "whois.afilias-srs.net", + "yodobashi" to "whois.nic.gmo", + "yoga" to "whois-dub.mm-registry.com", + "youtube" to "domain-registry-whois.l.google.com", + "yt" to "whois.nic.fr", + "yun" to "whois.teleinfo.cn", + "za" to "whois.ac.za", + "ac.za" to "whois.ac.za", + "alt.za" to "whois.alt.za", + "co.za" to "coza-whois.registry.net.za", + "edu.za" to "whois.co.za", + "gov.za" to "whois.gov.za", + "mil.za" to "whois.co.za", + "net.za" to "net-whois.registry.net.za", + "ngo.za" to "whois.co.za", + "nom.za" to "whois.co.za", + "org.za" to "org-whois.registry.net.za", + "school.za" to "whois.co.za", + "tm.za" to "whois.co.za", + "web.za" to "web-whois.registry.net.za", + "zara" to "whois.afilias-srs.net", + "zip" to "domain-registry-whois.l.google.com", + "zm" to "whois.nic.zm", + "zone" to "whois.donuts.co", + "zuerich" to "whois.ksregistry.net", + "бг" to "whois.imena.bg", + "бел" to "whois.cctld.by", + "дети" to "whois.nic.xn--d1acj3b", + "ею" to "whois.eu", + "ком" to "whois.nic.xn--j1aef", + "мкд" to "whois.marnet.mk", + "москва" to "whois.nic.xn--80adxhks", + "онлайн" to "whois.online.rs.corenic.net", + "орг" to "whois.publicinterestregistry.net", + "рус" to "whois.nic.xn--p1acf", + "рф" to "whois.ripn.net", + "сайт" to "whois.online.rs.corenic.net", + "срб" to "whois.rnids.rs", + "укр" to "whois.dotukr.com", + "қаз" to "whois.nic.kz", + "հայ" to "whois.amnic.net", + "קום" to "whois.nic.xn--9dbq2a", + "ابوظبي" to "whois.afilias-srs.net", + "الجزائر" to "whois.nic.dz", + "السعودية" to "whois.nic.net.sa", + "العليان" to "whois.nic.xn--mgba7c0bbn0a", + "امارات" to "whois.aeda.net.ae", + "ایران" to "whois.nic.ir", + "بيتك" to "whois.nic.xn--ngbe9e0a", + "بھارت" to "whois.inregistry.net", + "سورية" to "whois.tld.sy", + "شبكة" to "whois.nic.xn--ngbc5azd", + "عمان" to "whois.registry.om", + "فلسطين" to "whois.pnina.ps", + "قطر" to "whois.registry.qa", + "كوم" to "whois.nic.xn--fhbei", + "مصر" to "whois.dotmasr.eg", + "موقع" to "whois.afilias-srs.net", + "همراه" to "whois.agitsys.net", + "कॉम" to "whois.nic.xn--11b4c3d", + "नेट" to "whois.nic.xn--c2br7g", + "भारत" to "whois.inregistry.net", + "संगठन" to "whois.publicinterestregistry.net", + "ভারত" to "whois.inregistry.net", + "ਭਾਰਤ" to "whois.inregistry.net", + "ભારત" to "whois.inregistry.net", + "இந்தியா" to "whois.inregistry.net", + "சிங்கப்பூர்" to "whois.sgnic.sg", + "భారత్" to "whois.inregistry.net", + "คอม" to "whois.nic.xn--42c2d9a", + "ไทย" to "whois.thnic.co.th", + "გე" to "whois.itdc.ge", + "みんな" to "domain-registry-whois.l.google.com", + "グーグル" to "domain-registry-whois.l.google.com", + "コム" to "whois.nic.xn--tckwe", + "世界" to "whois.nic.xn--rhqv96g", + "中信" to "whois.gtld.knet.cn", + "中国" to "cwhois.cnnic.cn", + "中國" to "cwhois.cnnic.cn", + "中文网" to "whois.afilias-srs.net", + "企业" to "whois.donuts.co", + "佛山" to "whois.ngtld.cn", + "信息" to "whois.ngtld.cn", + "八卦" to "whois.nic.xn--45q11c", + "公司" to "whois.ngtld.cn", + "公益" to "whois.conac.cn", + "台湾" to "whois.twnic.net.tw", + "台灣" to "whois.twnic.net.tw", + "商城" to "whois.gtld.knet.cn", + "商店" to "whois.donuts.co", + "嘉里" to "whois.nic.xn--w4rs40l", + "嘉里大酒店" to "whois.nic.xn--w4r85el8fhu5dnra", + "在线" to "whois.afilias-srs.net", + "大众汽车" to "whois.afilias-srs.net", + "大拿" to "whois.nic.xn--pssy2u", + "娱乐" to "whois.donuts.co", + "工行" to "whois.afilias-srs.net", + "广东" to "whois.ngtld.cn", + "微博" to "whois.afilias-srs.net", + "慈善" to "whois.gtld.knet.cn", + "我爱你" to "whois.gtld.knet.cn", + "手机" to "whois.afilias-srs.net", + "政务" to "whois.conac.cn", + "政府" to "whois.nic.xn--mxtq1m", + "新加坡" to "whois.sgnic.sg", + "新闻" to "whois.nic.xn--efvy88h", + "时尚" to "whois.gtld.knet.cn", + "机构" to "whois.publicinterestregistry.net", + "淡马锡" to "whois.afilias-srs.net", + "游戏" to "whois.donuts.co", + "澳門" to "whois.monic.mo", + "点看" to "whois.nic.xn--3pxu8k", + "移动" to "whois.afilias.net", + "组织机构" to "whois.publicinterestregistry.net", + "网址" to "whois.registry.knet.cn", + "网店" to "whois.nic.xn--hxt814e", + "网站" to "whois.afilias-srs.net", + "网络" to "whois.ngtld.cn", + "联通" to "whois.imena.bg", + "诺基亚" to "whois.afilias-srs.net", + "谷歌" to "domain-registry-whois.l.google.com", + "购物" to "whois.afilias-srs.net", + "集团" to "whois.gtld.knet.cn", + "電訊盈科" to "whois.afilias-srs.net", + "飞利浦" to "whois.nic.xn--kcrx77d1x4a", + "香格里拉" to "whois.nic.xn--5su34j936bgsg", + "香港" to "whois.hkirc.hk", + "닷넷" to "whois.nic.xn--t60b56a", + "닷컴" to "whois.nic.xn--mk1bu44c", + "삼성" to "whois.kr", + "한국" to "whois.kr", + ) +} diff --git a/proprietary/src/test/kotlin/rip/crit/commonip/proprietary/LiveProviderTest.kt b/proprietary/src/test/kotlin/rip/crit/commonip/proprietary/LiveProviderTest.kt new file mode 100644 index 0000000..69863ab --- /dev/null +++ b/proprietary/src/test/kotlin/rip/crit/commonip/proprietary/LiveProviderTest.kt @@ -0,0 +1,13 @@ +package rip.crit.commonip.proprietary + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable + +class LiveProviderTest { + @Test + @EnabledIfEnvironmentVariable(named = "COMMONIP_LIVE_TESTS", matches = "true") + fun `starlink live feed has records in isolated opt in test`() { + assertTrue(StarlinkGeoFeed().pull().records.isNotEmpty()) + } +} diff --git a/proprietary/src/test/kotlin/rip/crit/commonip/proprietary/ProviderTest.kt b/proprietary/src/test/kotlin/rip/crit/commonip/proprietary/ProviderTest.kt new file mode 100644 index 0000000..45a20b2 --- /dev/null +++ b/proprietary/src/test/kotlin/rip/crit/commonip/proprietary/ProviderTest.kt @@ -0,0 +1,19 @@ +package rip.crit.commonip.proprietary + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ProviderTest { + @Test + fun `open geofeed uses injected fixture transport`() { + val feed = + OpenGeoFeed(HttpTransport { _, _ -> "203.0.113.0/24,US,CA,Los Angeles,90001" }) + .pull("https://fixture.invalid/feed.csv") + assertEquals("Los Angeles", feed.records.single().city) + } + + @Test + fun `starlink endpoint is opt in provider`() { + assertEquals("https://geoip.starlinkisp.net/feed.csv", StarlinkGeoFeed.URL) + } +} diff --git a/scripts/commonip.service b/scripts/commonip.service new file mode 100644 index 0000000..61d4bb8 --- /dev/null +++ b/scripts/commonip.service @@ -0,0 +1,12 @@ +[Unit] +Description=CommonIP node +After=network-online.target + +[Service] +User=commonip +WorkingDirectory=/opt/commonip +ExecStart=/opt/commonip/commonip +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/scripts/deploy-ssh.sh b/scripts/deploy-ssh.sh new file mode 100755 index 0000000..f696fcb --- /dev/null +++ b/scripts/deploy-ssh.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +deploy_host="${1:?host is required}" +deploy_user="${2:?user is required}" +deploy_path="${COMMONIP_REMOTE_PATH:-/opt/commonip}" + +./gradlew :app:nativeImage + +ssh "${deploy_user}@${deploy_host}" "sudo useradd --system --create-home --shell /usr/sbin/nologin commonip 2>/dev/null || true; sudo install -d -o commonip -g commonip ${deploy_path}" +scp app/build/native/commonip "${deploy_user}@${deploy_host}:/tmp/commonip" +ssh "${deploy_user}@${deploy_host}" "sudo install -o commonip -g commonip -m 0755 /tmp/commonip ${deploy_path}/commonip" +scp scripts/commonip.service "${deploy_user}@${deploy_host}:/tmp/commonip.service" +ssh "${deploy_user}@${deploy_host}" "sudo install -m 0644 /tmp/commonip.service /etc/systemd/system/commonip.service; sudo systemctl daemon-reload; sudo systemctl enable --now commonip" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..fe175a1 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,13 @@ +pluginManagement { + includeBuild("build-logic") +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +rootProject.name = "commonip" + +include("app", "proprietary", "packages") +include(":packages:serialize", ":packages:rawr", ":packages:ml", ":packages:decentraland") +include(":packages:decentraland:kademlia", ":packages:decentraland:git", ":packages:decentraland:chksum")