Зачем вообще это нужно?
Так получилось, что с работы мне довольно часто надо получить ssh доступ к своему домашнему компьютеру, а провайдер выдает белый, но динамически меняющийся ip адрес. Разумеется, выбор пал на динамический dns и я взял первого попавшегося бесплатного провайдера no-ip. Их демон прекрасно справлялся с задачей, меняя dns-запись на бесплатном домене третьего уровня от сервиса, а на моем домене был прописан CNAME на их домен.
Все это прекрасно работало до того момента, как я купил себе Zyxel Keenetic Giga. Он дружит с no-ip из коробки, но почему-то с моего домена теперь зайти не получалось. Эту проблему можно было бы решить покупкой статического ip у провайдера, записью в конфигурации ssh по прекрасному гайду от amarao, но так же не интересно! Итак, пришло время написать свой сервис!
Откуда, собственно, брать ip адрес?
Первым делом я задался именно этим вопросом. Можно было использовать один из бесплатных STUN-серверов (stun-клиент для go, благо, есть на github), можно было бы терроризировать какой-нибудь сервис, но я был намерен проверять свой адрес как можно чаще. Так как у меня есть свой сервер, на который я могу установить что угодно, то я просто решил написать до безумия простой сервис.
upd: еще несколько способов решения проблемы:
- Скачать Dynamic DNS Client для cloudflare (от spuf)
- Использовать OpenVPN (от aml) или SoftEther
- Вместо своего сервиса айпи определять с помощью других (спасибо david_mz)
- Отдавать ip прямо из nginx с помощью echo
- Или просто поставить в cron скрипт tofik
Сервис, который просто выдает 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 } }
