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