initial commit

This commit is contained in:
2025-07-12 20:40:57 +02:00
commit 4f6b9af0ed
26 changed files with 1618 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
package com.infendro.ddns
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
object Http {
val client = HttpClient {
install(ContentNegotiation) {
json()
}
}
}

View File

@@ -0,0 +1,110 @@
package com.infendro.ddns
import com.infendro.cli.argument.string
import com.infendro.cli.argument.stringOrElse
import com.infendro.cli.argument.strings
import com.infendro.cli.cli
import com.infendro.ddns.Argument.CONFIG
import com.infendro.ddns.Argument.DOMAIN
import com.infendro.ddns.Argument.IP_SERVICE
import com.infendro.ddns.Argument.KEY
import com.infendro.ddns.Argument.SECRET_KEY
import com.infendro.ddns.Argument.SUBDOMAIN
import com.infendro.ddns.argument.ipService
import com.infendro.ddns.config.Config
import com.infendro.ddns.dns.DnsService
import com.infendro.ddns.dns.DnsServices.PORKBUN
import com.infendro.ddns.dns.model.DnsRecord
import com.infendro.ddns.dns.porkbun.Porkbun
import com.infendro.ddns.ip.IpService
private object Argument {
val CONFIG = stringOrElse("config", "/etc/ddns.conf")
val DOMAIN = string("domain")
val SUBDOMAIN = strings("subdomain", min = 1)
val IP_SERVICE = ipService("ip-service")
// Porkbun
val KEY = string("key")
val SECRET_KEY = string("secret-key")
}
fun main(
args: Array<String>,
) {
cli {
arguments(CONFIG)
execute {
val config by CONFIG
Config.get(config)
.run {
val domain by DOMAIN
val subdomains by SUBDOMAIN
val ipService by IP_SERVICE
val dnsService = when (name) {
PORKBUN -> {
val key by KEY
val secretkey by SECRET_KEY
Porkbun(key, secretkey)
}
else -> throw Exception()
}
dnsService.update(ipService, domain, subdomains)
}
}
command(PORKBUN) {
arguments(
DOMAIN,
SUBDOMAIN,
IP_SERVICE,
KEY,
SECRET_KEY,
)
execute {
val domain by DOMAIN
val subdomains by SUBDOMAIN
val ipService by IP_SERVICE
val key by KEY
val secretkey by SECRET_KEY
val dnsService = Porkbun(key, secretkey)
dnsService.update(ipService, domain, subdomains)
}
}
}.run(args)
}
fun Config.run(
block: Config.Service.() -> Unit,
) {
for (service in services) {
service.block()
}
}
fun DnsService.update(
ipService: IpService,
domain: String,
subdomains: List<String>,
) {
val (ipv4, ipv6) = ipService.ip
val records = getRecords(domain, subdomains)
val aRecords = records
.filter { it.type == DnsRecord.Type.A }
val aaaaRecords = records
.filter { it.type == DnsRecord.Type.AAAA }
updateRecords(aRecords, ipv4)
updateRecords(aaaaRecords, ipv6)
}

View File

@@ -0,0 +1,34 @@
package com.infendro.ddns.argument
import com.infendro.cli.Command
import com.infendro.cli.argument.Argument
import com.infendro.cli.argument.Parser
import com.infendro.ddns.ip.IpService
import com.infendro.ddns.ip.IpServices
object IpServiceParser : Parser<IpService> {
override fun parse(
text: String?,
): IpService {
return IpServices.get(text!!)
}
}
class IpServiceOrDefaultArgument(
override val name: String,
) : Argument.SingleOrElse<IpService>() {
override val other: IpService
get() = IpServices.DEFAULT
override val parser: Parser<IpService>
get() = IpServiceParser
}
fun ipService(
name: String,
) = IpServiceOrDefaultArgument(name)
fun Command.Builder.ipService(
name: String,
) = IpServiceOrDefaultArgument(name)
.also { argument(it) }

View File

@@ -0,0 +1,128 @@
package com.infendro.ddns.config
import com.infendro.cli.argument.Argument.*
import com.infendro.ddns.config.Regex.ARGUMENT
import com.infendro.ddns.config.Regex.EMPTY_LINES
import kotlin.reflect.KProperty
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readString
class Config(
val services: List<Service>,
) {
class Service(
val name: String,
val arguments: List<Argument>,
) {
class Argument(
val name: String,
val value: String,
)
operator fun <T> Single<T>.getValue(
thisRef: Any?,
property: KProperty<*>,
): T {
return parser.parse(
arguments
.find { it.name == name }!!
.value
)
}
operator fun <T> SingleOrElse<T>.getValue(
thisRef: Any?,
property: KProperty<*>,
): T {
return arguments
.find { it.name == name }
?.let { parser.parse(it.value) }
?: other
}
operator fun <T> SingleOrNull<T>.getValue(
thisRef: Any?,
property: KProperty<*>,
): T? {
return arguments
.find { it.name == name }
?.let { parser.parse(it.value) }
}
operator fun <T> Multiple<T>.getValue(
thisRef: Any?,
property: KProperty<*>,
): List<T> {
return arguments
.filter { it.name == name }
.map { parser.parse(it.value) }
}
}
companion object {
fun get(
path: String,
): Config {
val path = Path(path)
val source = SystemFileSystem.source(path).buffered()
val text = source
.readString()
.process()
return parse(text)
}
private fun String.process(): String {
val builder = StringBuilder()
for (line in lines()) {
val comment = line.indexOf("""//""")
if (comment != -1) {
builder.appendLine(
line
.slice(0..<comment)
.trim()
)
} else {
builder.appendLine(
line.trim()
)
}
}
return builder.toString().trim()
}
private fun parse(
text: String,
): Config {
val services = text
.split(EMPTY_LINES)
.map { parseService(it) }
return Config(services)
}
private fun parseService(
text: String,
): Service {
val lines = text.lines()
val name = lines[0]
val arguments = lines
.drop(1)
.map { parseArgument(it) }
return Service(name, arguments)
}
private fun parseArgument(
text: String,
): Service.Argument {
val match = ARGUMENT.find(text)!!
val name = match.groups["name"]!!.value
val value = match.groups["value"]!!.value
return Service.Argument(name, value)
}
}
}

View File

@@ -0,0 +1,6 @@
package com.infendro.ddns.config
object Regex {
val EMPTY_LINES = Regex("""\n{2,}""")
val ARGUMENT = Regex("""(?<name>[a-z]+(?:-[a-z]+)*)(?:=(?<value>.*))?""")
}

View File

@@ -0,0 +1,25 @@
package com.infendro.ddns.dns
import com.infendro.ddns.dns.model.DnsRecord
abstract class DnsService {
abstract fun getRecords(
domain: String,
subdomains: List<String>,
): List<DnsRecord>
abstract fun updateRecord(
record: DnsRecord,
value: String,
)
fun updateRecords(
records: List<DnsRecord>,
value: String,
) {
for (record in records) {
if (record.value == value) continue
updateRecord(record, value)
}
}
}

View File

@@ -0,0 +1,5 @@
package com.infendro.ddns.dns
object DnsServices {
const val PORKBUN = "porkbun"
}

View File

@@ -0,0 +1,5 @@
package com.infendro.ddns.dns
object Regex {
val DOMAIN = Regex("""(?:(?<subdomain>(?:\w+\.)*\w+)\.)?(?<domain>\w+\.\w+)""")
}

View File

@@ -0,0 +1,31 @@
package com.infendro.ddns.dns.model
data class DnsRecord(
val id: String,
val type: Type,
val domain: Domain,
val value: String,
val ttl: Int,
val priority: Int?,
) {
data class Domain(
val subdomain: String?,
val domain: String,
) {
val full: String
get() = if (subdomain != null) {
"$subdomain.$domain"
} else {
domain
}
}
enum class Type {
NS, A, AAAA, CNAME, MX, TXT;
companion object {
val address: List<Type>
get() = listOf(A, AAAA)
}
}
}

View File

@@ -0,0 +1,88 @@
package com.infendro.ddns.dns.porkbun
import com.infendro.ddns.Http.client
import com.infendro.ddns.dns.Regex.DOMAIN
import com.infendro.ddns.dns.DnsService
import com.infendro.ddns.dns.model.DnsRecord
import com.infendro.ddns.dns.porkbun.dto.request.GetRecordsRequest
import com.infendro.ddns.dns.porkbun.dto.request.UpdateRecordRequest
import com.infendro.ddns.dns.porkbun.dto.response.GetRecordsResponse
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.runBlocking
class Porkbun(
val key: String,
val secretkey: String,
) : DnsService() {
override fun getRecords(
domain: String,
subdomains: List<String>,
): List<DnsRecord> = runBlocking {
val response = client.post("https://api.porkbun.com/api/json/v3/dns/retrieve/$domain") {
val request = getRecordsRequest()
contentType(ContentType.Application.Json)
setBody(request)
}
val body = response.body<GetRecordsResponse>()
body.records
.map { model(it) }
.filter { it.type in DnsRecord.Type.address && (it.domain.subdomain ?: "") in subdomains }
}
override fun updateRecord(
record: DnsRecord,
value: String,
): Unit = runBlocking {
client.post("https://api.porkbun.com/api/json/v3/dns/edit/${record.domain.domain}/${record.id}") {
val request = updateRecordRequest(record, value)
contentType(ContentType.Application.Json)
setBody(request)
}
}
private fun model(
record: GetRecordsResponse.Record,
): DnsRecord {
val match = DOMAIN.matchEntire(record.name)!!
val domain = match.groups["domain"]!!.value
val subdomain = match.groups["subdomain"]?.value
return DnsRecord(
id = record.id,
type = DnsRecord.Type.valueOf(record.type),
domain = DnsRecord.Domain(
subdomain = subdomain,
domain = domain,
),
value = record.content,
ttl = record.ttl.toInt(),
priority = record.prio?.toInt(),
)
}
private fun getRecordsRequest(): GetRecordsRequest {
return GetRecordsRequest(
apikey = key,
secretapikey = secretkey,
)
}
private fun updateRecordRequest(
record: DnsRecord,
ip: String,
): UpdateRecordRequest {
return UpdateRecordRequest(
apikey = key,
secretapikey = secretkey,
type = record.type.toString(),
name = record.domain.subdomain ?: "",
content = ip,
ttl = record.ttl.toString(),
)
}
}

View File

@@ -0,0 +1,9 @@
package com.infendro.ddns.dns.porkbun.dto.request
import kotlinx.serialization.Serializable
@Serializable
data class GetRecordsRequest(
val apikey: String,
val secretapikey: String,
)

View File

@@ -0,0 +1,13 @@
package com.infendro.ddns.dns.porkbun.dto.request
import kotlinx.serialization.Serializable
@Serializable
data class UpdateRecordRequest(
val apikey: String,
val secretapikey: String,
val type: String,
val name: String,
val content: String,
val ttl: String,
)

View File

@@ -0,0 +1,21 @@
package com.infendro.ddns.dns.porkbun.dto.response
import kotlinx.serialization.Serializable
@Serializable
data class GetRecordsResponse(
val status: String,
val cloudflare: String,
val records: Array<Record>,
) {
@Serializable
data class Record(
val id: String,
val type: String,
val name: String,
val content: String,
val ttl: String,
val prio: String?,
val notes: String?,
)
}

View File

@@ -0,0 +1,9 @@
package com.infendro.ddns.ip
abstract class IpService {
abstract val ipv4: String
abstract val ipv6: String
val ip: List<String>
get() = listOf(ipv4, ipv6)
}

View File

@@ -0,0 +1,17 @@
package com.infendro.ddns.ip
import com.infendro.ddns.ip.ipify.Ipify
object IpServices {
const val IPIFY = "ipify"
val DEFAULT = Ipify
fun get(
name: String,
): IpService {
return when (name) {
IPIFY -> Ipify
else -> throw Exception()
}
}
}

View File

@@ -0,0 +1,24 @@
package com.infendro.ddns.ip.ipify
import com.infendro.ddns.Http
import com.infendro.ddns.ip.IpService
import com.infendro.ddns.ip.ipify.dto.response.GetIpResponse
import io.ktor.client.call.body
import io.ktor.client.request.get
import kotlinx.coroutines.runBlocking
object Ipify : IpService() {
override val ipv4: String
get() = runBlocking {
val response = Http.client.get("https://api.ipify.org?format=json")
val body = response.body<GetIpResponse>()
body.ip
}
override val ipv6: String
get() = runBlocking {
val response = Http.client.get("https://api64.ipify.org?format=json")
val body = response.body<GetIpResponse>()
body.ip
}
}

View File

@@ -0,0 +1,8 @@
package com.infendro.ddns.ip.ipify.dto.response
import kotlinx.serialization.Serializable
@Serializable
data class GetIpResponse(
val ip: String,
)