simple-ddns-client/cmd/simple-ddns-client/main.go

200 lines
4.9 KiB
Go

/*
Copyright (C) 2025 fijxu@nadeko.net
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, version 3.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"time"
)
type Porkbun struct {
ApiKey string `json:"apiKey"`
SecretApiKey string `json:"secretApiKey"`
Domain string `json:"domain"`
Type string `json:"type"`
// rfc1035 defines: "TTL: positive values of a signed 32 bit number."
Ttl int32 `json:"ttl"`
Data PorkbunData
}
type PorkbunData struct {
SecretApiKey *string `json:"secretapikey"`
ApiKey *string `json:"apikey"`
Ip string `json:"content"`
Ttl string `json:"ttl"`
}
type Config struct {
UpdateInterval int `json:"updateInterval"`
Provider string `json:"provider"`
DnsServer string `json:"dnsServer"`
Porkbun Porkbun `json:"porkbun"`
}
var client *http.Client
var config *Config
// https://stackoverflow.com/questions/59889882/specifying-dns-server-for-lookup-in-go
// This will only work for looking up the IP address of the domain that we
// want to update. Other requests like HTTP ones will use the system DNS
// servers.
var resolver = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: time.Millisecond * time.Duration(5000),
}
if config.DnsServer == "" {
// TODO: This is called two times. idk why
log.Print("\"dnsServer\" key is empty. Using default DNS server")
return d.DialContext(ctx, network, address)
}
return d.DialContext(ctx, network, config.DnsServer)
},
}
// TODO: Use UPNP, NAT-PMP or with PCP
func getIpAddress() string {
req := doGetRequest("https://api.ipify.org")
return string(req)
}
func (p *Porkbun) updateIp() {
dnsIp, err := resolver.LookupHost(context.Background(), p.Domain)
if err != nil {
log.Print("Failed to retrieve IP address for domain " + p.Domain)
}
log.Print("Current IP of the record " + p.Domain + " is " + dnsIp[0])
p.Data.Ip = getIpAddress()
if string(dnsIp[0]) == p.Data.Ip {
log.Print("No need to update the IP address of domain " + p.Domain + ". IP is already " + p.Data.Ip)
return
}
// Porkbun uses a string for the `"ttl"` key.
p.Data.Ttl = string(p.Ttl)
p.Data.ApiKey = &p.ApiKey
p.Data.SecretApiKey = &p.SecretApiKey
domainParts := strings.Split(p.Domain, ".")
// TODO: Support 4th level FQDNs. Ex: test.us.nadeko.net
domain := domainParts[1] + "." + domainParts[2]
subDomain := domainParts[0]
data, _ := json.Marshal(&p.Data)
queryUrl := domain + "/" + p.Type + "/" + subDomain
res := doPostRequest("https://api.porkbun.com/api/json/v3/dns/editByNameType/"+queryUrl, data)
// Porkbun returns `{"status":"SUCCESS"}` if the IP address has been
// updated successfully
if strings.Contains(string(res), "SUCCESS") {
log.Print("IP Address updated to " + p.Data.Ip + " for domain " + p.Domain)
} else {
log.Print("ERROR: " + string(res))
}
}
func (p *Config) updateIp() {
switch p.Provider {
case "porkbun":
p.Porkbun.updateIp()
}
}
func loadConfig(configPath string) *Config {
var config = &Config{}
file, err := os.ReadFile(configPath)
if err != nil {
log.Fatal("Failed to parse config file: " + err.Error() + ". Exiting")
}
err = json.Unmarshal(file, config)
if err != nil {
log.Fatal("Failed to parse config file: " + err.Error() + ". Exiting")
}
return config
}
func doGetRequest(url string) []byte {
request, err := http.NewRequest("GET", url, nil)
if err != nil {
log.Fatal(err.Error())
}
res, err := client.Do(request)
if err != nil {
log.Fatal(err.Error())
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
return body
}
func doPostRequest(url string, data []byte) []byte {
request, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
if err != nil {
log.Fatal(err.Error())
}
res, err := client.Do(request)
if err != nil {
log.Fatal(err.Error())
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
return body
}
func init() {
client = &http.Client{}
var configPath string
flag.StringVar(&configPath, "c", "/etc/simple-ddns-client/config.json", "config.json path")
flag.Parse()
config = loadConfig(configPath)
if config.UpdateInterval <= 0 {
log.Fatal("\"updateInterval\" has to be greater than 0")
}
}
func main() {
for {
config.updateIp()
log.Print("Sleeping for " + fmt.Sprint(config.UpdateInterval) + " seconds")
time.Sleep(time.Duration(config.UpdateInterval) * time.Second)
}
}