Featured image of post Cloudflare API를 이용한 Cloudflare DDNS Agent 개발

Cloudflare API를 이용한 Cloudflare DDNS Agent 개발

Cloudflare API를 통해서 DDNS Agent를 개발해보자

홈서버의 IP는 고정으로 가지기에 매우 힘들기에, DDNS를 세팅해줘야 한다.
현재 Cloudflare에서 도메인을 구매하여 DNS서비스를 이용하고 있는데,
Cloudflare에서의 DDNS 구축 설명에서는 Cloudflare API를 통해 자체개발하거나, DDClient를 이용하라고 한다.

직접 Cloudflare API를 Go 언어를 통해서 개발해보려고 한다.


📦 Cloudflare API 의존성 설치

Cloudflare API를 위한 코드를 받아준다.

1
go get github.com/cloudflare/cloudflare-go/v6

⚙️ 환경변수 설정

필요한 환경변수들은 아래와 같다:

1
2
3
CF_API_TOKEN        # Cloudflare API token (DNS_READ, DNS_WRITE requried)
ZONE_ID             # Cloudflare zone ID
DOMAIN_NAME         # DNS record name to update (e.g. home.example.com)

💻 코드

전체 코드는 아래와 같다:

  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
 92
 93
 94
 95
 96
 97
 98
 99
100
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"

	"github.com/cloudflare/cloudflare-go/v6"
	"github.com/cloudflare/cloudflare-go/v6/dns"
	"github.com/cloudflare/cloudflare-go/v6/option"
)

func main() {
	// Get IP..
	ip := getIP()
	name := os.Getenv("DOMAIN_NAME")
	zoneID := os.Getenv("ZONE_ID")

	client := cloudflare.NewClient(
		option.WithAPIToken(os.Getenv("CF_API_TOKEN")),
	)
	page, err := client.DNS.Records.List(context.TODO(), dns.RecordListParams{
		ZoneID: cloudflare.F(zoneID),
	})
	if err != nil {
		panic(err.Error())
	}

	res := page.Result
	for i := range res {
		if res[i].Name == name {
			if res[i].Content == ip {
				fmt.Println("DDNS record is up to date.")
				return
			} else {
				fmt.Printf("Updating DDNS record: A %v -> %v\n", res[i].Content, ip)
				updateRecord(client, zoneID, res[i].ID, name, ip)
				return
			}
		}
	}

	fmt.Printf("No such domain name: %v\n", name)
	fmt.Printf("Creating New Record...")
	createRecord(client, zoneID, name, ip)
}

func getIP() string {
	ipProvider := "https://api.ipify.org"
	res, err := http.Get(ipProvider)
	if err != nil {
		log.Fatalf("Error while requesting Public IP to %v: %v", ipProvider, err)
	}
	defer res.Body.Close()
	ip, err := io.ReadAll(res.Body)
	if err != nil {
		log.Fatalf("Error while reading response data from %v:  %v", ipProvider, err)
	}

	return string(ip)
}

func updateRecord(client *cloudflare.Client, zoneID, dnsRecordID, name, newIP string) {
	recordResponse, err := client.DNS.Records.Edit(
		context.TODO(),
		dnsRecordID,
		dns.RecordEditParams{
			ZoneID: cloudflare.F(zoneID),
			Body: dns.ARecordParam{
				Name:    cloudflare.F(name),
				TTL:     cloudflare.F(dns.TTL1),
				Type:    cloudflare.F(dns.ARecordTypeA),
				Content: cloudflare.F(newIP),
			},
		},
	)
	if err != nil {
		panic(err.Error())
	}
	fmt.Printf("%+v\n", recordResponse)
}

func createRecord(client *cloudflare.Client, zoneID, name, ip string) {
	recordResponse, err := client.DNS.Records.New(context.TODO(), dns.RecordNewParams{
		ZoneID: cloudflare.F(zoneID),
		Body: dns.ARecordParam{
			Name:    cloudflare.F(name),
			TTL:     cloudflare.F(dns.TTL1),
			Type:    cloudflare.F(dns.ARecordTypeA),
			Content: cloudflare.F(ip),
		},
	})
	if err != nil {
		panic(err.Error())
	}
	fmt.Printf("%+v\n", recordResponse)
}

IP 가져오기

ipify API를 통해서 Public IP주소를 쉽게 가져올 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
	// Get IP..
	ip := getIP()
// ...
}

// ...

func getIP() string {
	ipProvider := "https://api.ipify.org"
	res, err := http.Get(ipProvider)
	if err != nil {
		log.Fatalf("Error while requesting Public IP to %v: %v", ipProvider, err)
	}
	defer res.Body.Close()
	ip, err := io.ReadAll(res.Body)
	if err != nil {
		log.Fatalf("Error while reading response data from %v:  %v", ipProvider, err)
	}
	return string(ip)
}

main.go 주요 로직

퍼블릭 IPv4주소를 가져온 뒤, Cloudflare Client를 생성한다.
그 뒤, 도메인에 대한 DNS 레코드들을 가져온다.

각 레코드에 대해서, 이 서버가 광고하길 원하는 이름을 찾는다.
있다면, 업데이트를 시도하고, 없다면, 새로 만들기를 시도한다.

 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
func main() {
// ...
	name := os.Getenv("DOMAIN_NAME")
	zoneID := os.Getenv("ZONE_ID")

	client := cloudflare.NewClient(
		option.WithAPIToken(os.Getenv("CF_API_TOKEN")),
	)
	page, err := client.DNS.Records.List(context.TODO(), dns.RecordListParams{
		ZoneID: cloudflare.F(zoneID),
	})
	if err != nil {
		panic(err.Error())
	}

	res := page.Result
	for i := range res {
		if res[i].Name == name {
			if res[i].Content == ip {
				fmt.Println("DDNS record is up to date.")
				return
			} else {
				fmt.Printf("Updating DDNS record: A %v -> %v\n", res[i].Content, ip)
				updateRecord(client, zoneID, res[i].ID, name, ip)
				return
			}
		}
	}

	fmt.Printf("No such domain name: %v\n", name)
	fmt.Printf("Creating New Record...")
	createRecord(client, zoneID, name, ip)
}

레코드 업데이트

레코드를 업데이트하는 코드이다.
cloudflare.F()는 Cloudflare SDK에서의 제네릭 헬퍼이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func updateRecord(client *cloudflare.Client, zoneID, dnsRecordID, name, newIP string) {
	recordResponse, err := client.DNS.Records.Edit(
		context.TODO(),
		dnsRecordID,
		dns.RecordEditParams{
			ZoneID: cloudflare.F(zoneID),
			Body: dns.ARecordParam{
				Name:    cloudflare.F(name),
				TTL:     cloudflare.F(dns.TTL1),         // Auto-TTL
				Type:    cloudflare.F(dns.ARecordTypeA), // A Record
				Content: cloudflare.F(newIP),
			},
		},
	)
	if err != nil {
		panic(err.Error())
	}
	fmt.Printf("%+v\n", recordResponse)
}

레코드 생성

New()로 새로운 레코드 생성 요청을 할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func createRecord(client *cloudflare.Client, zoneID, name, ip string) {
	recordResponse, err := client.DNS.Records.New(context.TODO(), dns.RecordNewParams{
		ZoneID: cloudflare.F(zoneID),
		Body: dns.ARecordParam{
			Name:    cloudflare.F(name),
			TTL:     cloudflare.F(dns.TTL1),
			Type:    cloudflare.F(dns.ARecordTypeA),
			Content: cloudflare.F(ip),
		},
	})
	if err != nil {
		panic(err.Error())
	}
	fmt.Printf("%+v\n", recordResponse)
}

🏗️ 빌드

이제, 프로그램을 빌드한다.

1
2
3
go build -o ddns-agent main.go
chmod +x ddns-agent
sudo mv ddns-agent /usr/local/bin/ddns-agent

🔄 자동으로 동작하게 하기

광고하길 원하는 서버에서 주기적으로 동작시켜야 한다.
Cron을 이용할 수 있지만, 더 깔끔한 관리를 위해 Systemd가 관리하도록 해보자.

Linux(Systemd)

/etc/systemd/system/ddns.service파일을 생성해서, 아래와 같이 작성한다.
Environment=<key>=<value>에서 환경변수 값을 채워주자.

 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
# /etc/systemd/system/ddns.service
[Unit]
Description=DDNS Agent (Cloudflare)
# Execute when network service is available
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot # Job-like service
ExecStart=/usr/local/bin/ddns-agent # Execute binary
# Environment variables
Environment=CF_API_TOKEN=xxxx
Environment=ZONE_ID=xxxx
Environment=DOMAIN_NAME=xxxx

## Security
# No privilege escalation
NoNewPrivileges=true
# Isolate tmpfs
PrivateTmp=true
# ReadOnly System directories(/etc, /usr, ...)
ProtectSystem=strict
# Cannot Access /home
ProtectHome=true

[Install]
WantedBy=multi-user.target # Multi-user unit

/etc/systemd/system/ddns.timer파일을 만들어서, 주기적으로 동작하는 트리거를 만든다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# /etc/systemd/system/ddns.timer
[Unit]
Description=Run DDNS Agent periodically

[Timer]
OnBootSec=30 # First execution: 30s after boot
OnUnitActiveSec=5min # 5-min period
Persistent=true # Ensures executed once after inactivate time

[Install]
WantedBy=timers.target # Timer unit

아래 명령으로 적용한다.

1
2
3
sudo systemctl daemon-reload
sudo systemctl enable ddns.timer
sudo systemctl start ddns.timer

잘 등록되어있는지 확인한다.

1
systemctl list-timers | grep ddns

MacOS(launchd)

MacOS를 쓰는 경우, Systemd 대신, Launchd를 쓴다.

사용자 로그인세션부터가 아닌 부팅시점부터 도리고 싶다면, LaunchAgents 대신 LaunchDaemons를 써야 한다.

yourname에는 홈 유저네임을 쓰면 된다.

 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
# ~/Library/LaunchAgents/com.yourname.ddns-agent.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.yourname.ddns-agent</string>

    <!-- Program -->
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/ddns-agent</string>
    </array>

    <!-- EnvironmentVariables key and its dictionary -->
    <key>EnvironmentVariables</key>
    <dict>
        <key>CF_API_TOKEN</key>
        <string>YOUR_CF_API_TOKEN</string>
        <key>ZONE_ID</key>
        <string>YOUR_CF_ZONE_ID</string>
        <key>DOMAIN_NAME</key>
        <string>YOUR_DDNS_RECORD_NAME</string>
    </dict>

    <!-- AutoRun when Startup -->
    <key>RunAtLoad</key>
    <true/>

    <key>StartInterval</key>
    <integer>300</integer> <!-- 5 mins interval-->

    <!-- stdout/stderr -->
    <key>StandardOutPath</key>
    <string>/usr/local/var/log/ddns.log</string>
    <key>StandardErrorPath</key>
    <string>/usr/local/var/log/ddns.err</string>
</dict>
</plist>

아래 명령으로 적용한다.

1
2
3
mkdir -p /usr/local/var/log # 로그 존재하도록 정리
launchctl load ~/Library/LaunchAgents/com.yourname.ddns-agent.plist
launchctl start com.yourname.ddns-agent

잘 등록되어있는지 확인한다:

1
launchctl list | grep ddns

📚 References

Hugo로 만듦
JimmyStack 테마 사용 중