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`.
```
ddns porkbun \
ddns porkbun update \
--key=<key> \
--secret-key=<secret key> \
--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.
```
porkbun
key=<key>
secret-key=<secret key>
domain=example.com
subdomain=
subdomain=test
[
{
"type": "porkbun",
"key": "<key>",
"secretKey": "<secret key>"
"domain": "example.com",
"subdomains": [
"",
"test"
]
}
]
```
To use this config file, execute the command `ddns --config=<config file>`.

View File

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

View File

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

View File

@@ -1,110 +1,5 @@
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.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
import com.infendro.ddns.command.ddns
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)
}
fun main(args: Array<String>) = ddns.run(*args)

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
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
import com.infendro.ddns.dns.DnsService
import com.infendro.ddns.ip.IpService
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class Config(
val services: List<Service>,
) {
class Service(
val name: String,
val arguments: List<Argument>,
) {
class Argument(
val name: String,
val value: String,
)
interface Config {
@Serializable
sealed class Entry {
abstract val domain: String
abstract val subdomains: List<String>
abstract val ipService: IpService?
abstract val dnsService: DnsService
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)
@Serializable
@SerialName("porkbun")
class Porkbun(
val key: String,
val secretKey: String,
override val domain: String,
override val subdomains: List<String>,
@Serializable(with = IpServiceSerializer::class)
override val ipService: IpService? = null,
) : Entry() {
override val dnsService: DnsService = com.infendro.ddns.dns.porkbun.Porkbun(key, secretKey)
}
}
}

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
import com.infendro.ddns.dns.model.DnsRecord
import com.infendro.ddns.ip.IpService
interface DnsService {
fun getRecords(
domain: String,
subdomains: List<String>,
): List<DnsRecord>
fun getRecords(domain: String, subdomains: List<String>): List<DnsRecord>
fun updateRecord(
record: DnsRecord,
value: String,
)
fun updateRecord(record: DnsRecord, value: String)
fun updateRecords(
records: List<DnsRecord>,
value: String,
) {
fun updateRecords(records: List<DnsRecord>, value: String) {
for (record in records) {
if (record.value == value) continue
updateRecord(record, value)
}
}
companion object {
const val PORKBUN = "porkbun"
fun update(domain: String, subdomains: List<String>, ipService: IpService) {
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 full: String
get() = if (subdomain != null) {
"$subdomain.$domain"
} else {
domain
get() = when {
subdomain != null -> "$subdomain.$domain"
else -> domain
}
}

View File

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

View File

@@ -1,16 +1,9 @@
package com.infendro.ddns.ip
import com.infendro.ddns.ip.ipify.Ipify
interface IpService {
val ipv4: String
val ipv6: String
val ip: Pair<String, String>
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 {
override val ipv4: String
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>()
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)