1. 背景
前阵子被种草了群晖的NAS,心水已久,终于等到打折,狠下心入手了一台DS920。它能满足很多对私有云的需求,例如云盘/云笔记/云相册/离线下载/自建流媒体服务器等等。DSM系统用起来很方便,但有时工作需要从外网访问NAS,如果使用群晖提供的QuickConnect,速度很是令人堪忧。于是就有了外网直连访问家里NAS的想法。想法实现的其中一环,就是DDNS。
群晖DSM中自带了DDNS客户端,但是不支持更新泛域名的记录值。其他也有很多开源的DDNS客户端,直接用即可。不过想着最近在学习Kotlin和Vert.x,这是一个实践的好机会,于是就有了今天的这篇记录。
2. 实现思路
2.1 准备工作
2.2 需要实现的基础功能
- 对接DNSPod运营商
- 更新DNS解析的记录值
- 计划任务
- 提供Docker镜像方便部署
3. 开动
3.1 新建工程
使用Vert.x提供的 [App Generator]: https://start.vertx.io/ 创建一个工程。导入我们需要用到的工具包。以下是项目的依赖情况。
1 2 3 4 5 6 7 8 9
| dependencies { implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.0") implementation("io.vertx:vertx-web:$vertxVersion") implementation("io.vertx:vertx-lang-kotlin:$vertxVersion") implementation("com.tencentcloudapi:tencentcloud-sdk-java-dnspod:$tencentcloudVersion") implementation("ch.qos.logback:logback-classic:$logbackVersion") }
|
3.2 实现获取公网IP函数
有两种方式可以获得公网IP。
由于家庭宽带网络环境复杂,从网卡可能会无法获取或不准确。所以此处我们选择后者。以下是部分代码片段。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
const val SOHU_GET_IP_URL = "http://pv.sohu.com/cityjson?ie=utf-8"
fun getIp(): String { val request = HttpRequest.newBuilder(URI(SOHU_GET_IP_URL)).build() val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()) var responseBody = response.body() responseBody = responseBody.substring(responseBody.indexOf("{"), responseBody.lastIndexOf("}") + 1) log.info("当前公网IP => $responseBody") return JsonObject(responseBody).getString("cip") }
|
3.3 定义配置类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
| object Config { private val log: Logger = LoggerFactory.getLogger(this.javaClass)
val DEFAULT_CONFIG_FILE_NAME = "config.json"
val DEFAULT_EXTERNAL_CONFIG_FILE_URI = "/config/config.json"
private val properties : ConfigProperties
init { val internalSource = ClassLoader.getSystemResourceAsStream(DEFAULT_CONFIG_FILE_NAME) val configMap: HashMap<String, Any> = objectMapper.readValue(internalSource, jacksonTypeRef<HashMap<String, Any>>()) val externalSource = File(DEFAULT_EXTERNAL_CONFIG_FILE_URI) if (externalSource.exists() && externalSource.isFile) { log.debug("加载外部配置文件") val externalProperties = objectMapper.readValue(externalSource, jacksonTypeRef<HashMap<String, Any>>()) configMap.putAll(externalProperties) } val configJson = objectMapper.writeValueAsString(configMap) properties = objectMapper.readValue(configJson, jacksonTypeRef<ConfigProperties>()) log.info("Config load success! => $configJson") }
fun getProperties(): ConfigProperties = properties }
@Serializable data class ConfigProperties( var type: TypeEnum, var schedule: ScheduleProperties, var api: APIProperties )
@Serializable data class ScheduleProperties( var enabled: Boolean = true, var interval: Long = 300, )
@Serializable data class APIProperties( var dnspod: DNSPodProperties )
@Serializable data class DNSPodProperties( var secretId: String, var secretKey: String, var domain: String )
|
配置文件则如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| { "type": "DNSPod", "schedule": { "interval": 300, "enabled": false }, "api": { "dnspod": { "secretId": "xxxxxxxxxxxxxxxxxxxxxx", "secretKey": "xxxxxxxxxxxxxxxxxxxxxx", "domain": "xxxxx.xxx" } } }
|
3.4 对接DNSPod的API
对接DNSPod的SDK,并且预留对接其他运营商的接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
|
object CloudApiManager { private val log: Logger = LoggerFactory.getLogger(this.javaClass) private val executor: CloudApi init { val type = Config.getProperties().type when (type) { NONE -> { throw RuntimeException("未指定域名运营商,请设置type参数!") } DNSPod -> executor = DNSPodApi } } fun execute() = executor.execute() }
interface CloudApi { fun execute() }
object DNSPodApi : CloudApi {
private val log: Logger = LoggerFactory.getLogger(this.javaClass)
private val secretId: String = Config.getProperties().api.dnspod.secretId private val secretKey: String = Config.getProperties().api.dnspod.secretKey private val domain: String = Config.getProperties().api.dnspod.domain
private val client: DnspodClient = DnspodClient( Credential(secretId, secretKey), "", ClientProfile(ClientProfile.SIGN_TC3_256, HttpProfile().also { it.endpoint = "dnspod.tencentcloudapi.com" }) )
override fun execute() { try { val recordList = getDescribeRecordList().recordList.map { it.recordId }.toTypedArray() modifyRecordBatch(recordList) } catch (e: Exception) { log.error("更新失败", e) } }
@Throws(TencentCloudSDKException::class) private fun getDescribeRecordList(): DescribeRecordListResponse { val req = DescribeRecordListRequest().also { it.domain = domain it.recordType = "A" } return client.DescribeRecordList(req) }
@Throws(TencentCloudSDKException::class) private fun modifyRecordBatch(recordIds: Array<Long>): ModifyRecordBatchResponse { return modifyRecordBatch(recordIds, getIp()) }
@Throws(TencentCloudSDKException::class) private fun modifyRecordBatch(recordIds: Array<Long>, ip: String): ModifyRecordBatchResponse { val req = ModifyRecordBatchRequest().also { it.recordIdList = recordIds it.change = "value" it.changeTo = ip } val resp = client.ModifyRecordBatch(req) log.info("修改记录值成功 :: 结果 => " + ModifyRecordBatchResponse.toJsonString(resp)) return resp }
}
|
3.5 实现计划任务
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| object Schedule { private val log: Logger = LoggerFactory.getLogger(this.javaClass) private val timer: Timer = Timer("ddnsw-schedule") private val interval: Long = Config.getProperties().schedule.interval fun start() { timer.schedule(timerTask { CloudApiManager.execute() }, 0, interval) log.info("计划任务启动") } fun stop() { timer.cancel() log.info("计划任务停止") } }
|
3.6 实现RestAPI控制程序
这一步是提供了Rest的API来控制程序。我们需要实现四个HTTP接口,分别是:开始/停止定时任务、查询公网IP和更新DNS解析记录值。以下是Vert.x设置路由和启动HttpServer的部分代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class MainVerticle : AbstractVerticle() { override fun start(startPromise: Promise<Void>) { val router = Router.router(vertx) router.route().handler(ResponseContentTypeHandler.create()) router.get("/ip").handler(GetIpHandler()) router.get("/update").handler(UpdateHandler()) router.get("/schedule/start").handler(ScheduleStartHandler()) router.get("/schedule/stop").handler(ScheduleStopHandler()) vertx.createHttpServer() .requestHandler(router) .listen(9090) .onSuccess { startPromise.complete() log.info("HTTP Server started on port ${it.actualPort()}") } .onFailure { startPromise.fail(it.cause) } } }
|
3.7 打包镜像/上传到DockerHub
1 2 3 4 5
| FROM mcr.microsoft.com/java/jre:11u13-zulu-alpine COPY ./build/libs/ddnsw-fat.jar /root WORKDIR /root EXPOSE 9090 CMD ["java", "-jar","ddnsw-fat.jar"]
|
1 2
| $ docker build -t weidajiao/ddnsw:latest . $ docker push weidajiao/ddnsw:latest
|
3.8 在NAS上启动
使用docker-compose在NAS上启动容器。
1 2 3 4 5 6 7 8 9
| version: "3" services: ddnsw: image: "weidajiao/ddnsw:latest" container_name: "ddnsw" ports: - "9090:9090" volumes: - ./config:/config
|
4. 总结
本次主要是使用Kotlin、Vert.x框架实现了一个DDNS客户端,目的是为了NAS能在外网直连访问,顺便练练手,熟悉一下Kotlin和Vert.x。
到此目的已经达到,不过程序只是达到一个基本可用的状态,还有很多细节需要再琢磨琢磨。例如提供多种配置方式(环境变量/命令参数等)、对接多个域名运营商、适配宿主机直接运行、内置多个IP获取接口做备用等等。
OK,本篇到此结束。