1. 背景

前阵子被种草了群晖的NAS,心水已久,终于等到打折,狠下心入手了一台DS920。它能满足很多对私有云的需求,例如云盘/云笔记/云相册/离线下载/自建流媒体服务器等等。DSM系统用起来很方便,但有时工作需要从外网访问NAS,如果使用群晖提供的QuickConnect,速度很是令人堪忧。于是就有了外网直连访问家里NAS的想法。想法实现的其中一环,就是DDNS。

群晖DSM中自带了DDNS客户端,但是不支持更新泛域名的记录值。其他也有很多开源的DDNS客户端,直接用即可。不过想着最近在学习Kotlin和Vert.x,这是一个实践的好机会,于是就有了今天的这篇记录。

2. 实现思路

2.1 准备工作

  • 一枚域名
  • 一枚公网IP
  • 一台电脑

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。

  • 从本地网卡获取。

  • 调用公开的获取公网IP接口。

由于家庭宽带网络环境复杂,从网卡可能会无法获取或不准确。所以此处我们选择后者。以下是部分代码片段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 搜狐获取公网IP接口URL
*/
const val SOHU_GET_IP_URL = "http://pv.sohu.com/cityjson?ie=utf-8"

/**
* 调用搜狐接口获取公网IP
*/
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
}


/**
* 配置类
*
* @param type 域名服务商
* @param schedule 计划任务
* @param api 域名服务商详细配置
*/
@Serializable
data class ConfigProperties(
var type: TypeEnum,
var schedule: ScheduleProperties,
var api: APIProperties
)

/**
* 计划任务配置
* 默认开启,5分钟执行一次
*
* @param enabled 开启状态,默认开启。
* @param interval 计划任务时间间隔,单位秒。默认300。
*/
@Serializable
data class ScheduleProperties(
var enabled: Boolean = true,
var interval: Long = 300,
)

/**
* API
*
* @param dnspod DNSPod配置
*/
@Serializable
data class APIProperties(
var dnspod: DNSPodProperties
)

/**
* DNSPod配置
*
* @param secretId
* @param secretKey
* @param domain
*/
@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
/**
* API操作类
*/
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()
}

/**
* DNSPod实现
*/
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

  • Dockerfile如下
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,本篇到此结束。