2019年10月

某天接触到某个用Golang实现的程序,不仅体积小,还支持几乎所有种类的CPU,于是下决心学习一下Go这门语言。虽然刚出来的时候就想学,但那时据说有很多坑(比如语法可能会变),就放弃了。现在连被成为垃圾的包管理也有了升级,觉得是时候去学。恰好要把一个二级域名绑定IP的小服务迁移到VPS上,于是干脆用Go重新实现(原来是用Python3)这个服务。

首先要阅读相关教程。初学者教程当然是官方入门教程:
https://tour.go-zh.org/

官方入门教程太简单(毕竟Go本身语法就是简单),还需要阅读其它相关知识:
1)Go搭建一个Web服务器
https://github.com/astaxie/build-web-application-with-golang/blob/master/zh/03.2.md
2)golang读取json配置文件
[https://blog.csdn.net/benben_2015/article/details/79134734]
3)文件读写
https://wiki.jikexueyuan.com/project/the-way-to-go/12.2.html
4)go-extend,获取请求的IP的代码
https://github.com/thinkeridea/go-extend/blob/master/exnet/ip.go
5)golang 发送GET和POST示例
https://segmentfault.com/a/1190000013262746
6)GoDNS中dnspod客户端的代码
https://github.com/TimothyYe/godns/blob/master/handler/dnspod/dnspod_handler.go
7)Go语言(golang)的错误(error)处理的推荐方案
https://www.flysnow.org/2019/01/01/golang-error-handle-suggestion.html

这个小服务,就是个web服务。客户端发起带有key和token的请求,此服务会验证有效的授权,然后把对应的二级域名与客户端IP绑定。配置信息以json格式保存在文本文件。客户端IP会记录在对应log文件,以方便每次比较客户端IP是否变化了。每次更新二级域名与IP的绑定,则会记录log。相关文件及代码如下:

配置文件,config.json:

{
"ServIpPort": ":12345"
,"DnsKey": "dnspod的key"
,"DnsToken": "dnspod的token"
,"DomainId": "dnspod的域名id"
,"SubDomainId": {"abc":"dnspod的二级域名id", "efg":"dnspod的二级域名id"}
,"Users": [
        {
                "Key":"client1"
                ,"Token":"aaa123456"
                ,"SubDomains": ["abc"]
        }
        ,{
                "Key":"client2"
                ,"Token":"xxx789012"
                ,"SubDomains": ["efg"]
        }
]
}

小服务的代码,ddnsServ.go:

package main

import (
    "fmt"
    "log"
    "net"
    "net/http"
    "net/url"
    "strings"
    "os"
    "path/filepath"
    "io/ioutil"
    "time"
    "encoding/json"
)

type User struct {
    Key string
    Token string
    SubDomains []string
}

type Config struct {
    ServIpPort string
    DnsKey string
    DnsToken string
    DomainId string
    SubDomainId map[string]string /*key:sub domain name, value:sub domain id*/
    Users []User
}

var (
    curPath string
    ipLogPath string
    historyPath string
    config Config
)

func init() {
    curPath, _ = filepath.Abs(filepath.Dir(os.Args[0])) 
    ipLogPath = curPath + "/ip"
    historyPath = curPath + "/log"
    for _, path := range []string{ipLogPath, historyPath} {
        if err := initPath(path); err != nil {
            fmt.Printf("Init failed. Error info:%s\n", err)
            os.Exit(-1)
            return
        }
    }

    if err := initConfig(curPath + "/config.json"); err != nil {
        fmt.Printf("Init failed. Error info:%s\n", err)
        os.Exit(-1)
        return
    }
}

func initPath(path string) error {
    s, err := os.Stat(path)
    if err == nil && !s.IsDir() {
        return fmt.Errorf("The path is existed, but it is not a directory! Path is:%s", path)
    }
    if err != nil && os.IsNotExist(err) {
        e := os.Mkdir(path, os.ModePerm)
        return e 
    }
    return nil
}

func initConfig(configPath string) error {
    configData, err := ioutil.ReadFile(configPath)
    if err != nil {
        return fmt.Errorf("Failed to read config file: %s! Error info: \n%s", configPath, err)
    }

    err = json.Unmarshal(configData, &config)
    if err != nil {
        return fmt.Errorf("Failed to load config data! Error info: \n%s", err)
    }
    return nil
}

// get client IP address 
func GetClientIP(r *http.Request) string {
    xForwardedFor := r.Header.Get("X-Forwarded-For")
    ip := strings.TrimSpace(strings.Split(xForwardedFor, ",")[0])
    if ip != "" {
        return ip
    }

    ip = strings.TrimSpace(r.Header.Get("X-Real-Ip"))
    if ip != "" {
        return ip
    }

    if ip, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)); err == nil {
        return ip
    }

    return ""
}

func GetLogFilePath(logPath string, subDomain string) string {
    return fmt.Sprintf("%s/%s.log", logPath, subDomain)
}

// get the file path which saved ip address of subDomain
func GetIpLog(subDomain string) string {
    path := GetLogFilePath(ipLogPath, subDomain)
    buf, err := ioutil.ReadFile(path)
    if err != nil {
        return ""
    }
    return string(buf)
}

func SaveIpLog(subDomain string, ip string) {
    path := GetLogFilePath(ipLogPath, subDomain)
    ioutil.WriteFile(path, []byte(ip), 0644)
    /*
    err := ioutil.WriteFile(path, []byte(ip), 0644)
    if err != nil {
        panic(err.Error())
    }
    */
}

func SaveHistoryLog(subDomain string, ip string) error {
    path := GetLogFilePath(historyPath, subDomain)
    logFile, err := os.OpenFile(path, os.O_CREATE | os.O_WRONLY | os.O_APPEND, 0644)
    if err != nil {
        return fmt.Errorf("Failed to open history log file: %s! Error info: \n%s", path, err)
    }
    defer logFile.Close()

    nowStr := time.Now().Format("2006-01-02 15:04:05")
    log := fmt.Sprintf("%s, ip:%s\n", nowStr, ip)
    logByte := []byte(log)
    n, err := logFile.Write(logByte)
    if err == nil && n < len(logByte) {
        return fmt.Errorf("Failed to save history log file: %s! Error info: \nwrite file failed", path)
    }
    return nil
}

func UpdateDns(subDomain string, ip string) error {
    values := url.Values{}
    values.Add("login_token", config.DnsKey + "," + config.DnsToken)
    values.Add("format", "json")
    values.Add("lang", "en")
    values.Add("error_on_empty", "no")

    values.Add("domain_id", config.DomainId)
    values.Add("record_id", config.SubDomainId[subDomain])
    values.Add("sub_domain", subDomain)
    values.Add("record_type", "A")
    values.Add("record_line", "默认")
    values.Add("value", ip)

    client := &http.Client{}
    req, err := http.NewRequest("POST", "https://dnsapi.cn/Record.Modify", strings.NewReader(values.Encode()))
    if err != nil {
        // handle error
    }

    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("Accept", "text/json")
    resp, err := client.Do(req)

    defer resp.Body.Close()

    _, err2 := ioutil.ReadAll(resp.Body)
//    fmt.Println(string(s))
    if err2 != nil {
        return err2
    }
    return nil
}

func handler(w http.ResponseWriter, r *http.Request) {
    // Verify authorization
    var subDomains []string
    key := r.PostFormValue("key") // get key of POST
    token := r.PostFormValue("token") // get token of POST
    for _, user := range config.Users {
        if user.Key == key && user.Token == token {
            subDomains = user.SubDomains
            break
        }
    }
    if subDomains == nil || len(subDomains) <= 0 {
        w.WriteHeader(http.StatusNotFound)
        return
    }

    // get IP
    ip := GetClientIP(r)
    fmt.Fprintf(w, "%s", ip)

    for _, subDomain := range subDomains {
        // get last IP of subDomain
        ipLog := GetIpLog(subDomain) 
        if ip == ipLog {
            // IP is not changed
            continue
        }
    
        // update DNS, bind IP to subDomain
        err := UpdateDns(subDomain, ip)
        if err == nil {
            // update success, save new IP
            SaveIpLog(subDomain, ip)
            SaveHistoryLog(subDomain, ip)
        }
    }
}

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(config.ServIpPort, nil))
}

关于部署,就用nginx弄个反向代理,指向这个小服务的端口。由于VPS上装了Debian9,可以配置systemd来设置系统服务。

客户端只需发起post请求,把key和token发过来就可以了。以下用curl实现:

#!/bin/sh

#curl命令的参数解析:
# -X 请求的方法。这里用了POST,在HTTPS传输中,数据被加密
# --connect-timeout 连接超时时间,单位:秒
# -m/--max-time 数据传输的最大允许时间,单位:秒
# https://rpi.f...... 请求的URL
# -H/--header 请求头。要设置多个请求头,则设置多个-H参数
# -d/--data 请求数据。
curl -k -X POST --connect-timeout 5 -m 10 https://youdomain.xxx:12345/api/update_dns -H 'cache-control: no-cache' -H 'content-type: application/x-www-form-urlencoded' -d 'key=client1&token=aaa123456'

目前这个小服务工作良好。除了体积有点大(约7MB),其它都挺满意的。