Refactor and Bump version to 1.1.0

This commit is contained in:
2026-03-14 12:08:44 +01:00
parent 90b8f3f08a
commit 67509e9062
20 changed files with 219 additions and 361 deletions

View File

@@ -1,43 +0,0 @@
on:
push:
branches:
- main
env:
INFENDRO__TOKEN: ${{ secrets.TOKEN }}
jobs:
release:
runs-on: linux
steps:
- uses: actions/checkout@v4
- uses: actions/cache/restore@v4
with:
key: gradle
path: |
~/.gradle/caches/
~/.gradle/wrapper/
~/.konan/
- uses: actions/setup-java@v4
with:
java-version: '24'
distribution: 'temurin'
- run: |
./gradlew assemble
mv ./build/bin/linuxX64/releaseExecutable/ddns.kexe ./ddns
- uses: actions/upload-artifact@v3
with:
name: ddns
path: ./ddns
- uses: actions/cache/save@v4
with:
key: gradle
path: |
~/.gradle/caches/
~/.gradle/wrapper/
~/.konan/

View File

@@ -15,7 +15,7 @@ The resulting binary will be located in `./build/bin/releaseExecutable/`.
The following command updates all A and AAAA DNS records in Porkbun for `example.com` and `test.example.com`. The following command updates all A and AAAA DNS records in Porkbun for `example.com` and `test.example.com`.
``` ```
ddns porkbun \ ddns porkbun update \
--key=<key> \ --key=<key> \
--secret-key=<secret key> \ --secret-key=<secret key> \
--domain=example.com \ --domain=example.com \
@@ -27,12 +27,18 @@ Alternatively, a file can be used to declare one or more configurations.
The above example can be declared as follows. The above example can be declared as follows.
``` ```
porkbun [
key=<key> {
secret-key=<secret key> "type": "porkbun",
domain=example.com "key": "<key>",
subdomain= "secretKey": "<secret key>"
subdomain=test "domain": "example.com",
"subdomains": [
"",
"test"
]
}
]
``` ```
To use this config file, execute the command `ddns --config=<config file>`. To use this config file, execute the command `ddns --config=<config file>`.

View File

@@ -1,7 +1,7 @@
import com.infendro.plugin.infendro import com.infendro.plugin.infendro
group = "com.infendro" group = "com.infendro"
version = "1.0.0" version = "1.1.0"
plugins { plugins {
alias(libs.plugins.infendro) alias(libs.plugins.infendro)

View File

@@ -1,11 +1,11 @@
[versions] [versions]
infendro = "1.0.0" infendro = "1.1.0"
kotlin = "2.2.21" kotlin = "2.3.0"
io = "0.8.2" io = "0.9.0"
coroutines = "1.10.2" coroutines = "1.10.2"
serialization = "1.9.0" serialization = "1.10.0"
cli = "1.3.0" cli = "1.4.0"
ktor = "3.3.3" ktor = "3.4.0"
[plugins] [plugins]
infendro = { id = "com.infendro.plugin", version.ref = "infendro" } infendro = { id = "com.infendro.plugin", version.ref = "infendro" }

View File

@@ -1,110 +1,5 @@
package com.infendro.ddns package com.infendro.ddns
import com.infendro.cli.argument.string import com.infendro.ddns.command.ddns
import com.infendro.cli.argument.stringOrElse
import com.infendro.cli.argument.strings
import com.infendro.cli.command.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.DnsService.Companion.PORKBUN
import com.infendro.ddns.dns.model.DnsRecord
import com.infendro.ddns.dns.porkbun.Porkbun
import com.infendro.ddns.ip.IpService
private object Argument { fun main(args: Array<String>) = ddns.run(*args)
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

@@ -1,36 +0,0 @@
package com.infendro.ddns.argument
import com.infendro.cli.argument.Argument
import com.infendro.cli.command.Command
import com.infendro.ddns.ip.IpService
import com.infendro.ddns.ip.ipify.Ipify
object IpServiceParser : Argument.Parser<IpService> {
override fun parse(
text: String?,
): IpService {
return when (text) {
IpService.IPIFY -> Ipify
else -> throw Exception()
}
}
}
class IpServiceOrDefaultArgument(
override val name: String,
) : Argument.SingleOrElse<IpService>() {
override val other: IpService
get() = IpService.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,12 @@
package com.infendro.ddns.command
import com.infendro.cli.command.argument.argument
import com.infendro.cli.command.option.option
import com.infendro.ddns.ip.ipify.Ipify
import com.infendro.ddns.parameter.ipService
internal object Parameters {
val DOMAIN = argument.string("domain")
val SUBDOMAINS = argument.string("subdomains").variable(min = 1)
val IP_SERVICE = option.ipService("ip-service").orElse(Ipify)
}

View File

@@ -0,0 +1,9 @@
package com.infendro.ddns.command
import com.infendro.cli.command.command
val ddns = command("ddns") {
execute { help() }
+update
+porkbun
}

View File

@@ -0,0 +1,29 @@
package com.infendro.ddns.command
import com.infendro.cli.command.command
import com.infendro.cli.command.option.option
import com.infendro.ddns.command.Parameters.DOMAIN
import com.infendro.ddns.command.Parameters.IP_SERVICE
import com.infendro.ddns.command.Parameters.SUBDOMAINS
import com.infendro.ddns.dns.porkbun.Porkbun
val porkbun = command("porkbun") {
execute { help() }
+command("update") {
val key = +option.string("key")
val secretKey = +option.string("secret-key")
register(DOMAIN, SUBDOMAINS)
register(IP_SERVICE)
execute {
val key by key
val secretKey by secretKey
val domain by DOMAIN
val subdomains by SUBDOMAINS
val ipService by IP_SERVICE
Porkbun(key, secretKey).update(domain, subdomains, ipService)
}
}
}

View File

@@ -0,0 +1,35 @@
package com.infendro.ddns.command
import com.infendro.cli.command.command
import com.infendro.cli.command.option.option
import com.infendro.ddns.config.Config
import com.infendro.ddns.ip.ipify.Ipify
import com.infendro.ddns.parameter.path
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readString
import kotlinx.serialization.json.Json
val update = command("update") {
val config = +option.path("config").orElse(Path("/etc/ddns.conf"))
execute {
val config by config
val source = SystemFileSystem.source(config).buffered()
val text = source.readString()
val entries: List<Config.Entry> = Json.decodeFromString(text)
update(entries)
}
}
private fun update(entries: List<Config.Entry>) {
for (entry in entries) {
val domain = entry.domain
val subdomains = entry.subdomains
val ipService = entry.ipService ?: Ipify
entry.dnsService.update(domain, subdomains, ipService)
}
}

View File

@@ -1,128 +1,29 @@
package com.infendro.ddns.config package com.infendro.ddns.config
import com.infendro.cli.argument.Argument.* import com.infendro.ddns.dns.DnsService
import com.infendro.ddns.config.Regex.ARGUMENT import com.infendro.ddns.ip.IpService
import com.infendro.ddns.config.Regex.EMPTY_LINES import kotlinx.serialization.SerialName
import kotlin.reflect.KProperty import kotlinx.serialization.Serializable
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.io.readString
class Config( interface Config {
val services: List<Service>, @Serializable
) { sealed class Entry {
class Service( abstract val domain: String
val name: String, abstract val subdomains: List<String>
val arguments: List<Argument>, abstract val ipService: IpService?
) { abstract val dnsService: DnsService
class Argument(
val name: String,
val value: String,
)
operator fun <T> Single<T>.getValue( @Serializable
thisRef: Any?, @SerialName("porkbun")
property: KProperty<*>, class Porkbun(
): T { val key: String,
return parser.parse( val secretKey: String,
arguments override val domain: String,
.find { it.name == name }!! override val subdomains: List<String>,
.value @Serializable(with = IpServiceSerializer::class)
) override val ipService: IpService? = null,
} ) : Entry() {
override val dnsService: DnsService = com.infendro.ddns.dns.porkbun.Porkbun(key, secretKey)
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,25 @@
package com.infendro.ddns.config
import com.infendro.ddns.ip.IpService
import com.infendro.ddns.ip.ipify.Ipify
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object IpServiceSerializer : KSerializer<IpService> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("IpService", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: IpService) = throw NotImplementedError()
override fun deserialize(decoder: Decoder): IpService {
val value = decoder.decodeString()
return when (value) {
"ipify" -> Ipify
else -> throw Exception()
}
}
}

View File

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

View File

@@ -1,29 +1,30 @@
package com.infendro.ddns.dns package com.infendro.ddns.dns
import com.infendro.ddns.dns.model.DnsRecord import com.infendro.ddns.dns.model.DnsRecord
import com.infendro.ddns.ip.IpService
interface DnsService { interface DnsService {
fun getRecords( fun getRecords(domain: String, subdomains: List<String>): List<DnsRecord>
domain: String,
subdomains: List<String>,
): List<DnsRecord>
fun updateRecord( fun updateRecord(record: DnsRecord, value: String)
record: DnsRecord,
value: String,
)
fun updateRecords( fun updateRecords(records: List<DnsRecord>, value: String) {
records: List<DnsRecord>,
value: String,
) {
for (record in records) { for (record in records) {
if (record.value == value) continue if (record.value == value) continue
updateRecord(record, value) updateRecord(record, value)
} }
} }
companion object { fun update(domain: String, subdomains: List<String>, ipService: IpService) {
const val PORKBUN = "porkbun" 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

@@ -13,10 +13,9 @@ data class DnsRecord(
val domain: String, val domain: String,
) { ) {
val full: String val full: String
get() = if (subdomain != null) { get() = when {
"$subdomain.$domain" subdomain != null -> "$subdomain.$domain"
} else { else -> domain
domain
} }
} }

View File

@@ -15,10 +15,7 @@ class Porkbun(
val key: String, val key: String,
val secretkey: String, val secretkey: String,
) : DnsService { ) : DnsService {
override fun getRecords( override fun getRecords(domain: String, subdomains: List<String>): List<DnsRecord> = runBlocking {
domain: String,
subdomains: List<String>,
): List<DnsRecord> = runBlocking {
val response = http.post("https://api.porkbun.com/api/json/v3/dns/retrieve/$domain") { val response = http.post("https://api.porkbun.com/api/json/v3/dns/retrieve/$domain") {
val request = GetRecordsRequest( val request = GetRecordsRequest(
apikey = key, apikey = key,
@@ -50,10 +47,7 @@ class Porkbun(
.filter { it.type in DnsRecord.Type.address && (it.domain.subdomain ?: "") in subdomains } .filter { it.type in DnsRecord.Type.address && (it.domain.subdomain ?: "") in subdomains }
} }
override fun updateRecord( override fun updateRecord(record: DnsRecord, value: String): Unit = runBlocking {
record: DnsRecord,
value: String,
): Unit = runBlocking {
http.post("https://api.porkbun.com/api/json/v3/dns/edit/${record.domain.domain}/${record.id}") { http.post("https://api.porkbun.com/api/json/v3/dns/edit/${record.domain.domain}/${record.id}") {
val request = UpdateRecordRequest( val request = UpdateRecordRequest(
apikey = key, apikey = key,

View File

@@ -1,16 +1,9 @@
package com.infendro.ddns.ip package com.infendro.ddns.ip
import com.infendro.ddns.ip.ipify.Ipify
interface IpService { interface IpService {
val ipv4: String val ipv4: String
val ipv6: String val ipv6: String
val ip: Pair<String, String> val ip: Pair<String, String>
get() = Pair(ipv4, ipv6) get() = Pair(ipv4, ipv6)
companion object {
const val IPIFY = "ipify"
val DEFAULT = Ipify
}
} }

View File

@@ -10,7 +10,7 @@ import kotlinx.coroutines.runBlocking
object Ipify : IpService { object Ipify : IpService {
override val ipv4: String override val ipv4: String
get() = runBlocking { get() = runBlocking {
val response = http.get("https://api.ipify.org?format=json") val response = http.get("https://api4.ipify.org?format=json")
val body = response.body<GetIpResponse>() val body = response.body<GetIpResponse>()
body.ip body.ip
} }

View File

@@ -0,0 +1,25 @@
package com.infendro.ddns.parameter
import com.infendro.cli.command.argument.ArgumentFactory
import com.infendro.cli.command.argument.argument
import com.infendro.cli.command.option.OptionFactory
import com.infendro.cli.command.option.option
import com.infendro.cli.parser.Parser
import com.infendro.ddns.ip.IpService
import com.infendro.ddns.ip.ipify.Ipify
object IpServiceParser : Parser<IpService>(IpService::class) {
override fun parse(text: String): Result<IpService> {
val service = when (text) {
"ipify" -> Ipify
else -> return failure(text)
}
return success(service)
}
}
fun ArgumentFactory.ipService(name: String, description: String? = null) =
argument(IpServiceParser, name, description)
fun OptionFactory.ipService(vararg names: String, description: String? = null) =
option(IpServiceParser, *names, description = description)

View File

@@ -0,0 +1,19 @@
package com.infendro.ddns.parameter
import com.infendro.cli.command.argument.ArgumentFactory
import com.infendro.cli.command.argument.argument
import com.infendro.cli.command.option.OptionFactory
import com.infendro.cli.command.option.option
import com.infendro.cli.parser.Parser
import kotlinx.io.files.Path
object PathParser : Parser<Path>(Path::class) {
override fun parse(text: String) =
success(Path(text))
}
fun ArgumentFactory.path(name: String, description: String? = null) =
argument(PathParser, name, description)
fun OptionFactory.path(vararg names: String, description: String? = null) =
option(PathParser, *names, description = description)