Go
9 July 2014

Свой dynamic dns на Go с помощью Cloudflare

From Sandbox Tutorial

Зачем вообще это нужно?


Так получилось, что с работы мне довольно часто надо получить ssh доступ к своему домашнему компьютеру, а провайдер выдает белый, но динамически меняющийся ip адрес. Разумеется, выбор пал на динамический dns и я взял первого попавшегося бесплатного провайдера no-ip. Их демон прекрасно справлялся с задачей, меняя dns-запись на бесплатном домене третьего уровня от сервиса, а на моем домене был прописан CNAME на их домен.

Все это прекрасно работало до того момента, как я купил себе Zyxel Keenetic Giga. Он дружит с no-ip из коробки, но почему-то с моего домена теперь зайти не получалось. Эту проблему можно было бы решить покупкой статического ip у провайдера, записью в конфигурации ssh по прекрасному гайду от amarao, но так же не интересно! Итак, пришло время написать свой сервис!

Откуда, собственно, брать ip адрес?


Первым делом я задался именно этим вопросом. Можно было использовать один из бесплатных STUN-серверов (stun-клиент для go, благо, есть на github), можно было бы терроризировать какой-нибудь сервис, но я был намерен проверять свой адрес как можно чаще. Так как у меня есть свой сервер, на который я могу установить что угодно, то я просто решил написать до безумия простой сервис.

upd: еще несколько способов решения проблемы:


Сервис, который просто выдает ip клиента

Назовем его yourip. Он всего лишь должен возвращать ip по GET-запросу на /ip.
Я решил использовать для простоты httprouter — cамый быстрый и простой роутер для go. Вот первый и единственный обработчик:
func PrintIp(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
	fmt.Fprint(w, r.Header.Get("X-Real-IP"))
}

Просто записываем значение заголовка «X-Real-IP» в ответ и все. Этот заголовок нам передаст nginx, если мы его настроим. А если к этому сервису обращаться планируется не через реверс-прокси, а напрямую, то потребуется использовать r.RemoteAddr вместо r.Header.Get(«X-Real-IP»).

Код программы полностью (также можно посмотреть на гитхабе):
package main

import (
	"fmt"
	"github.com/julienschmidt/httprouter"
	"log"
	"net/http"
	"flag"
)

// несколько параметров
var (
	port = flag.Int("port", 80, "port")   // собственно, порт сервиса
	host = flag.String("host", "", "host") // хост
	prefix = flag.String("prefix", "/ip", "uri prefix") // и путь
)

func PrintIp(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
	fmt.Fprint(w, r.Header.Get("X-Real-IP"))
}

func main() {
	// прочитаем параметры
	flag.Parse()
	// составим адрес
	addr := fmt.Sprintf("%s:%d", *host, *port)
	log.Println("listening on", addr)
	router := httprouter.New()
	// привяжем обработчик к url
	router.GET(*prefix, PrintIp)
	// и запустим наш сервер
	log.Fatal(http.ListenAndServe(addr, router))
}

Осталось настроить nginx. Достаточно будет примерно такой конфигурации:
upstream yourip {
	server locahost:888; # пусть наш сервис висит на этом порту
}
server {
        listen 80;
        location /ip {
                proxy_set_header        X-Real-IP $remote_addr;
                proxy_pass              http://yourip;
        }
}

И запустить наш сервис, например ./yourip -port=888
Проверить работу сервиса можно, перейдя по этой ссылке, также её можете использовать, если вам негде захостить сервис.

Как обновить запись в Cloudflare?


У cloudflare api есть метод rec_edit, который может изменить запись для определенного домена.
Узнаем идентификатор записи

Для начала надо как-то узнать id записи, в этом нам поможет другой метод — rec_load_all

Нам надо сделать POST-запрос примерно такого содержания:
curl https://www.cloudflare.com/api_json.html \
  -d 'a=rec_load_all' \
  -d 'tkn=8afbe6dea02407989af4dd4c97bb6e25' \
  -d 'email=sample@example.com' \
  -d 'z=example.com'

И надо его сделать в go. В этом нам помогут замечательные пакеты net/url и net/http
Вначале приготовим базовый url
// зададим заранее некоторые поля, чтобы не повторяться
func Url() (u url.URL) {
    u.Host = "www.cloudflare.com"
    u.Scheme = "https"
    u.Path = "api_json.html"
    return
}

Эта функция поможет нам не повторять код, т.к. мы будем делать в общей сумме два запроса к api.
А теперь добавим параметров:
u := Url()
// добавим дополнительные параметры
// возьмем (пустой) запрос из нашей url
values := u.Query()
values.Add("email", *email)
values.Add("tkn", *token)
values.Add("a", "rec_load_all")
values.Add("z", *domain)
// присвоим обратно полю RawQuery то, что у нас получилось
u.RawQuery = values.Encode()
reqUrl := u.String()

Для лучшего понимания можно посмотреть типы URL и Values.

Пришло время создать запрос и выполнить его.
client = http.Client{}
req, _ := http.NewRequest("POST", reqUrl, nil)
res, err := client.Do(req)


Чтобы обработать ответ в json, нам надо его десериализировать в какую-то структуру. Посмотрев пример ответа, я составил вот такую:
type AllResponse struct {
	Response struct {
		Records struct {
			Objects []struct {
				Id      string `json:"rec_id"`
				Name    string `json:"name"`
				Type    string `json:"type"`
				Content string `json:"content"`
			} `json:"objs"`
		} `json:"recs"`
	} `json:"response"`
}

Таким образом, мы получим лишь необходимые нам данные, когда будем парсить ответ:
// созданим переменную, куда будем парсить
response := &AllResponse{}
// создадим декодер
decoder := json.NewDecoder(res.Body)
// и распарсим ответ сервера в нашу структуру
err = decoder.Decode(response)

Теперь обработаем полученные данные, пройдясь по всем записям:
for _, v := range response.Response.Records.Objects {
	// и найдем запись нужного типа и имени
	if v.Name == *target && v.Type == "A" {
		// конвертируем из строки в число идентификатор
		id, _ := strconv.Atoi(v.Id)
		return id, v.Content, nil
	}
}

Наконец, мы нашли то, что нам нужно — идентификатор

Меняем запись

Нам снова потребуется создать запрос. Начнем собирать url:
u := Url()
values := u.Query()
values.Add("email", *email)
values.Add("tkn", *token)
values.Add("a", "rec_edit")
values.Add("z", *domain)
values.Add("type", "A")
values.Add("name", *target)
values.Add("service_mode", "0")
values.Add("content", ip)
values.Add("id", strconv.Itoa(id))
values.Add("ttl", fmt.Sprint(*ttl))

Теперь в нем есть вся информация, которая нужна для замены ip адреса. Осталось только создать запрос и выполнить его, как в прошлый раз
req, _ := http.NewRequest("POST", reqUrl, nil)
res, err := client.Do(req)

Собственно, на этом самое интересное заканчивается. Эти два запроса выносятся в отдельные функции, все нужные переменные — в флаги, и создается главный бесконечный цикл.

func main() {
	flag.Parse()
	// получим id и предыдущий ip
	id, previousIp, err := GetDnsId()
	if err != nil {
		log.Fatalln("unable to get dns record id:", err)
	}
	// создадим тикер, который позволит нам удобно каждые
	// 5 секунд проверять ip адрес
	ticker := time.NewTicker(time.Second * 5)
	// начнем наш бесконечный цикл
	for _ = range ticker.C {
		ip, err := GetIp()
		if err != nil {
			continue
		}
		if previousIp != ip {
			err = SetIp(ip, id)
			if err != nil {
				continue
			}
		}
		log.Println("updated to", ip)
		previousIp = ip
	}
}


На этом всё. Код можно найти на гитхабе

Код полностью
package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"strconv"
	"time"
)

// структура для парсинга ответа от api
type AllResponse struct {
	Response struct {
		Records struct {
			Objects []struct {
				Id      string `json:"rec_id"`
				Name    string `json:"name"`
				Type    string `json:"type"`
				Content string `json:"content"`
			} `json:"objs"`
		} `json:"recs"`
	} `json:"response"`
}

// и опять настраиваемые параметры
var (
	yourIpUrl = flag.String("url", "https://cydev.ru/ip", "Yourip service url")
	domain    = flag.String("domain", "cydev.ru", "Cloudflare domain")
	target    = flag.String("target", "me.cydev.ru", "Target domain")
	email     = flag.String("email", "ernado@ya.ru", "The e-mail address associated with the API key")
	token     = flag.String("token", "-", "This is the API key made available on your Account page")
	ttl       = flag.Int("ttl", 120, "TTL of record in seconds. 1 = Automatic, otherwise, value must in between 120 and 86400 seconds")
	// http клиент - у него есть метод Do, который нам пригодится
	client = http.Client{}
)

// зададим заранее некоторые поля, чтобы не повторяться
func Url() (u url.URL) {
	u.Host = "www.cloudflare.com"
	u.Scheme = "https"
	u.Path = "api_json.html"
	return
}

// SetIp устанавливает значение записи с заданным id
func SetIp(ip string, id int) error {
	u := Url()
	values := u.Query()
	values.Add("email", *email)
	values.Add("tkn", *token)
	values.Add("a", "rec_edit")
	values.Add("z", *domain)
	values.Add("type", "A")
	values.Add("name", *target)
	values.Add("service_mode", "0")
	values.Add("content", ip)
	values.Add("id", strconv.Itoa(id))
	values.Add("ttl", fmt.Sprint(*ttl))
	u.RawQuery = values.Encode()

	reqUrl := u.String()
	log.Println("POST", reqUrl)
	req, err := http.NewRequest("POST", reqUrl, nil)
	if err != nil {
		return err
	}
	res, err := client.Do(req)
	if err != nil {
		return err
	}
	if res.StatusCode != http.StatusOK {
		return errors.New(fmt.Sprintf("bad status %d", res.StatusCode))
	}
	return nil
}

// GetDnsId вовзращает id записи и её текущее значение
func GetDnsId() (int, string, error) {
	log.Println("getting dns record id")
	// начнем собирать url
	u := Url()
	// добавим дополнительные параметры
	values := u.Query()
	values.Add("email", *email)
	values.Add("tkn", *token)
	values.Add("a", "rec_load_all")
	values.Add("z", *domain)
	u.RawQuery = values.Encode()
	reqUrl := u.String()
	// создадим запрос, выполним его и проверим результат
	log.Println("POST", reqUrl)
	req, err := http.NewRequest("POST", reqUrl, nil)
	res, err := client.Do(req)
	if err != nil {
		return 0, "", err
	}
	if res.StatusCode != http.StatusOK {
		return 0, "", errors.New(fmt.Sprintf("bad status %d", res.StatusCode))
	}
	response := &AllResponse{}
	// создадим декодер
	decoder := json.NewDecoder(res.Body)
	// и распарсим ответ сервера в нашу структуру
	err = decoder.Decode(response)
	if err != nil {
		return 0, "", err
	}
	// пройдемся по всем записям
	for _, v := range response.Response.Records.Objects {
		// и найдем запись нужного типа и имени
		if v.Name == *target && v.Type == "A" {
			// конвертируем из строки в число идентификатор
			id, _ := strconv.Atoi(v.Id)
			return id, v.Content, nil
		}
	}
	// нужная нам запись не найдена
	return 0, "", errors.New("not found")
}

// GetIp() обращается к yourip сервису и возвращает наш ip адрес
func GetIp() (string, error) {
	res, err := client.Get(*yourIpUrl)
	if err != nil {
		return "", err
	}
	if res.StatusCode != http.StatusOK {
		return "", errors.New(fmt.Sprintf("bad status %d", res.StatusCode))
	}
	body, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return "", err
	}
	return string(body), nil
}

func main() {
	flag.Parse()
	id, previousIp, err := GetDnsId()
	if err != nil {
		log.Fatalln("unable to get dns record id:", err)
	}
	log.Println("found record", id, "=", previousIp)
	// создадим тикер, который позволит нам удобно каждые
	// 5 секунд проверять ip адрес
	ticker := time.NewTicker(time.Second * 5)
	// начнем наш бесконечный цикл
	for _ = range ticker.C {
		ip, err := GetIp()
		if err != nil {
			log.Println("err", err)
			continue
		}
		if previousIp != ip {
			err = SetIp(ip, id)
			if err != nil {
				log.Println("unable to set ip:", err)
				continue
			}
		}
		log.Println("updated to", ip)
		previousIp = ip
	}
}



+11
11.9k 62
Comments 25
Top of the day