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

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


Так получилось, что с работы мне довольно часто надо получить 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
	}
}


AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 25

    0
    Вот как я решил разрыв отношений с dyndns от dyn.com.
    Имеем asus wl-500gpv2 (прошивка code.google.com/p/wl500g/) и дроплет от DO.
    на роутере в crontab-е:
    */10 * * * * /opt/digitalocean-ddns
    а в скрипте /opt/digitalocean-ddns:
    #!/bin/sh /opt/bin/curl -k -s "https://api.digitalocean.com/domains/$DOMAIN_ID/records/$RECORD_ID/edit?client_id=[client_id]&api_key=[api_key]&data=$(nvram get wan0_ipaddr)"
    Также попросил саппорт уменшить TTL on DNS.
      0
      Таким же образом можно решить проблему и с cloudflare, если сервер не за NAT-ом. В моем случае приходилось дополнительно получать откуда-то IP.
        0
        curl -s checkip.dyndns.org | sed 's/[a-zA-Z/<> :]//g' | tr -d '\r'
          0
          curl cydev.ru/ip
          
            0
            -s нужно. Иначе могут быть чудеса :)
              0
              curl -s ifconfig.me
              И запомнить легче.
                0
                Довольно долго отвечает на запрос :)
                  0
                  curl -s ip.vlad.pro ну могу тогда это предложить)
                  root@vlad:~# cat /etc/nginx/sites-enabled/13-ip.vlad.pro 
                  server { 
                  	listen 80;
                  	server_name ip.vlad.pro;
                  	location / {
                  		echo_before_body $remote_addr;
                                  return 200;
                  	}
                  	
                  }
                  
                  
            0
            Я, когда решал аналогичную задачу (что интересно, тоже на go:)), составил небольшой список адресов для получения IP. Вдруг кому-то пригодится. Все возвращают простой IPv4:

            checkip.amazonaws.com/
            curlmyip.com/
            www.trackip.net/ip
            whatismyip.akamai.com/
            ifconfig.me/ip
            ipv4.icanhazip.com/
            shtuff.it/myip/text/
            cydev.ru/ip

            Интересно, что все публичные STUN-сервера у меня безбожно тормозили, поэтому пришлось от них отказаться и использовать вот эти сервисы.
          0
          Если есть свой сервер, поставьте туда OpenVPN и поднимите соединения до него из дома и с работы. У вас ещё и ssh-сеансы перестанут разрываться при смене IP.
            0
            Спасибо, как один из вариантов решения задачи. Также можно вместо OpenVPN использовать SoftEther.
              0
              Пользуюсь MOSH, прямо идеально для практически любых условий.
              +1
              А я просто скачал Dynamic DNS Client с www.cloudflare.com/resources-downloads
                0
                А я и не знал, что он у них есть. Спасибо ;)
                Но всегда интереснее написать свой велосипед на костыльном приводе.
                0
                Вместо демона на Go, отдающего IP, можно в самом nginx просто отдавать заголовок:
                add_header X-Remote-Addr $remote_addr;
                

                  0
                  По-моему можно просто
                  echo $remote_addr;
                  

                  В nginx.
                  В любом случае это не сильно упрощает дело, хотя решение более элегантное :)
                    0
                    Только если nginx собран с этим нестандартным модулем. add_header, имхо, самое общее решение.
                      0
                      add_header у меня и так используется же, я с помощью него передаю ip на сервис yourip, чтобы уже с него отослать ip не как заголовок, а просто как текст.

                      upd: я перепутал немного, у меня заголовок устанавливается для запроса, а вы предлагаете для ответа
                        0
                        Да, я предложил заменить демона на Go одной строчкой в конфиге nginx. Будет меньше точек отказа.
                          –1
                          Но тогда придется читать заголовки вместо простого тела ответа
                            +1
                            Чем чтение заголовков сложнее чтения тела? А вот выкинуть не нужное доп приложение — красота же.
                  0
                  Go справился с задачей.
                  Кстати, для сравнения, на PowerShell (без проверок и примесями C#) сегодня Ваша задача решается на примере ниже.
                  Если брать IP с адаптера, то код будет ещё короче.

                  $input_tkn = "ключ"
                  $input_email = "email"
                  $input_domain = "http://домен"
                  
                  # запрашиваем адрес с сайта dyndns
                  $wc = New-Object system.Net.WebClient
                  $answer = $wc.downloadString("http://checkip.dyndns.org/")
                  
                  # извлекаем IP-адрес из ответа
                  $ipPattern = "^.* ([\d][\d][\d]?.[\d][\d][\d]?.[\d][\d]?[\d]?.[\d]?[\d]?[\d]).*$"
                  $query = Select-String -InputObject $answer -Pattern $ipPattern
                  $myIp = $query.Matches[0].Groups[1].Value;
                  
                  # готовим и отправляем запрос
                  $postParams = @{a='rec_load_all';tkn=$input_tkn;email=$input_email;z=$input_domain;}
                  $cloudAnswer = Invoke-WebRequest -Uri https://www.cloudflare.com/api_json.html -Method POST -Body $postParams
                  $jsonResult = $cloudAnswer.Content
                  
                    0
                    Можно в вашем случае сократить на пару строчек код, использовав один из сервисов, которые отдают только ip.
                    Оставить просто
                    $answer = $wc.downloadString("https://cydev.ru/ip")

                    Кстати, кажется, что у вас пример не полный, т.к. нет, собственно, изменения ip и вообще извлечения id записи

                    А статья больше учебная, не просто так бейджик tutorial висит. Я подумал, что будет полезно постепенно расширять количество статей по go начального уровня.
                      0
                      Да, пример обрезан. Я поставил Вам и статье плюс, так как было интересно посмотреть на задачу,
                      которую я сам недавно решал в Windows с помощью PowerShell.
                      Раньше не хватало такого скриптого языка вроде PowerShell для Windows.
                      В нём можно использовать весь C# и много других встроенных средств ОС даже не загружая IDE.
                      Для задач вроде этой и других очень даже и неплохо.
                        0
                        Спасибо за ваш пример, тоже было интересно посмотреть, как это решается на PowerShell

                  Only users with full accounts can post comments. Log in, please.