Я писал коротко о DNS-сервере и какие типы записей бывают. На самом деле это достаточно сложная система, чтобы о ней так просто говорить. Но мы же храбрые люди и не боимся велосипедов! Попробуйем сделать DNS-сервер своими руками.
С чего начать?
Для начала нужно определиться для чего он нам нужен. Лично я для себя определил, что это должен быть сервер с поддержкой Forward-запросов и «черного списка» доменов. В дальнейшем я подумал а почему бы не прикрутить еще и «белый список«? Но я пока не представлял себе что это будет и для чего он мне. Позже я разобрался, но об этом позже.
Второй вопрос это на чем будет все это базироваться. Самая главная сложность — это формирование запросов как клиент и ответов как сервер. В счастью есть готовые библиотеки, и не нужно будет изобретать колеса. А вот все остальное…
Структуры
Где-то и как-то нужно всю информацию хранить. Думаю, для домашнего использования, вполне должен подойти тип map. А вот и сама структура(ы):
type DNSRecord struct {
Name string // Название доменного имени или его части
Type uint16 // Тип записи
Class uint16 // Тип класса записи
// Ttl uint32 // Время жизни
Addr *[]Addr
Mx *[]Mx
CName *[]CName
DName *[]DName
Ptr *[]Ptr
Txt *[]Txt
Srv *[]Srv
}
type Addr struct {
Addr net.IP // Адреса
Ttl time.Time // ТТЛ
}
type Mx struct {
Addr string // Адрес почтового сервера
Preference uint16 // Предпочтение
Ttl time.Time // TTL
}
type CName struct {
Addr string
Ttl time.Time
}
type DName struct {
Addr string
Ttl time.Time
}
type Ptr struct {
Addr string
Ttl time.Time
}
type Txt struct {
Addr string
Ttl time.Time
}
type Srv struct {
Priority uint16
Weight uint16
Port uint16
Target string
Ttl time.Time
}
Теперь есть еще другая проблема — потоконебезопасный тип map. Что-то с ним надо придумать:
type TypeList struct {
Mutex sync.RWMutex
List map[string]*DNSRecord
}
Ну и сами списки:
var BlackList *structs.TypeList
var WhiteList *structs.TypeList
var CacheDomain *structs.TypeList
И еще немного подготовки:
var RegexpLastComma = regexp.MustCompile(",$")
var RegexpLastDash = regexp.MustCompile(`\.$`)
var PrepareStr = regexp.MustCompile(`[\s\t]+`)
Эти регулярные выражения понадобятся для подготовки строк.
Обработчики
Думаю создание сервера не очень интересно. Гораздо интереснее обработка запросов:
func Handler(w dns.ResponseWriter, req *dns.Msg) {
var err error
var lines []dns.RR
defer w.Close()
m := new(dns.Msg)
m.Authoritative = true
m.SetReply(req)
for _, question := range req.Question {
var ls []dns.RR
ls, err = Question(question.Name, question.Qtype, &question)
lines = append(lines, ls...)
if err != nil {
log.Println(err)
}
}
m.Answer = append(m.Answer, lines...)
err = w.WriteMsg(m)
if err != nil {
log.Println(err)
}
}
Здесь мы из пакета читаем запросы по очереди и обрабатываем, подготавливая записи для ответа. Теперь сама функция Question:
func Question(name string, qtype uint16, question *dns.Question) (lines []dns.RR, err error) {
// Проверка в списке блокировок
if SearchInBlackList(name, qtype) != nil {
// Если в списке блокировки, то ничего не возвращаем, типа домена такого не существует
return
}
if qtype == dns.TypeA || qtype == dns.TypeAAAA {
if res := SearchInWhiteList(name, dns.TypeCNAME); res != nil {
lines = append(lines, PrepareMessage(question, res)...)
if len(*res.CName) > 0 {
for _, cname := range *res.CName {
var ls []dns.RR
ls, err = Question(cname.Addr, dns.TypeA, question)
if err != nil {
return
}
lines = append(lines, ls...)
ls, err = Question(cname.Addr, dns.TypeAAAA, question)
if err != nil {
return
}
lines = append(lines, ls...)
}
}
if len(lines) > 0 {
return
}
}
if res := SearchInWhiteList(name, dns.TypeDNAME); res != nil {
lines = append(lines, PrepareMessage(question, res)...)
if len(*res.DName) > 0 {
for _, dname := range *res.DName {
var ls []dns.RR
ls, err = Question(dname.Addr, dns.TypeA, question)
if err != nil {
return
}
lines = append(lines, ls...)
ls, err = Question(dname.Addr, dns.TypeAAAA, question)
if err != nil {
return
}
lines = append(lines, ls...)
}
}
if len(lines) > 0 {
return
}
}
}
if res := SearchInWhiteList(name, qtype); res != nil {
lines = append(lines, PrepareMessage(question, res)...)
if len(lines) > 0 {
return
}
}
// Проверим есть ли в кэше данные
if qtype == dns.TypeA || qtype == dns.TypeAAAA {
if res := SearchInCache(name, dns.TypeCNAME); res != nil {
lines = append(lines, PrepareMessage(question, res)...)
if len(*res.CName) > 0 {
for _, cname := range *res.CName {
var ls []dns.RR
ls, err = Question(cname.Addr, dns.TypeA, question)
if err != nil {
return
}
lines = append(lines, ls...)
ls, err = Question(cname.Addr, dns.TypeAAAA, question)
if err != nil {
return
}
lines = append(lines, ls...)
}
}
if len(lines) > 0 {
return
}
}
}
if res := SearchInCache(name, qtype); res != nil {
lines = append(lines, PrepareMessage(question, res)...)
if len(lines) > 0 {
return
}
}
// Опросим внешний DNS для поиска записей
msg := &dns.Msg{}
msg.SetQuestion(name, qtype)
c := &dns.Client{
Net: "udp",
ReadTimeout: time.Second * 5,
WriteTimeout: time.Second * 5,
}
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
ch := make(chan *dns.Msg, 1)
var wg sync.WaitGroup
// Функция отправления запроса на внешний DNS-сервер
requestToDNS := func(nameserver string) {
defer wg.Done()
m := dns.Msg{}
m.SetQuestion(name, qtype)
msg, _, err := c.Exchange(&m, nameserver)
if err != nil {
log.Printf("error: %s", err.Error())
return
}
ch <- msg
}
for _, nameserver := range Config.Nameservers {
wg.Add(1)
go requestToDNS(nameserver)
select {
case res := <-ch:
AddInCache(res)
case <-ticker.C:
continue
}
wg.Wait()
}
if qtype == dns.TypeA || qtype == dns.TypeAAAA {
if res := SearchInCache(name, dns.TypeCNAME); res != nil {
lines = append(lines, PrepareMessage(question, res)...)
if len(*res.CName) > 0 {
for _, cname := range *res.CName {
var ls []dns.RR
ls, err = Question(cname.Addr, dns.TypeA, question)
if err != nil {
return
}
lines = append(lines, ls...)
ls, err = Question(cname.Addr, dns.TypeAAAA, question)
if err != nil {
return
}
lines = append(lines, ls...)
}
}
if len(lines) > 0 {
return
}
}
}
if res := SearchInCache(name, qtype); res != nil {
lines = append(lines, PrepareMessage(question, res)...)
}
return
}
Функция получилась достаточно крупная и, возможно, не оптимальная. Ее надо пояснить.
Сначала ищем запись в черном списке. Если она найдена, то поиск останавливаем. После проверяем является ли запрос типа записи A или AAAA. Как раз это важный момент. Если запись являеися типом CNAME или DNAME, то мы не найдем запись и вернем клиенту пустой ответ, а это не правильно. После того как нашли CNAME или DNAME (если они, конечно, есть) нам снова нужно найти записи типа A или AAAA. Если мы в одном ответе не отдадим клиенту обе эти записи при наличии альясов, то у нас просто дальше работать не будет, потому что тот же браузер не делает запросы. У меня так встал FireFox и apt. Было обидно.
Далее после проверки записей в своем кэше, если ничего не найдено, делаем запрос в указанных серверах. Соответственно обрабатываем ответы и помещаем их в кэш. Это и будет у нас Forward-сервер. Причем, если TTL еще не закончился, то запись будет отдаваться клиенту из кэша, что уменьшит внешний запрос и, по идее, должен ускорить время ответа.
Собственно все.
Организация поиска
Реализация поиска в списках достаточно проста:
// Найдем домен в листе блокировки
func SearchInBlackList(domain string, domainType uint16) *structs.DNSRecord {
return SearchInList(domain, domainType, BlackList)
}
// Найдем домен в листе допущенных
func SearchInWhiteList(domain string, domainType uint16) *structs.DNSRecord {
return SearchInList(domain, domainType, WhiteList)
}
// Найдем домен в кэше
func SearchInCache(domain string, domainType uint16) *structs.DNSRecord {
return SearchInList(domain, domainType, CacheDomain)
}
// Найдем домен в списке
func SearchInList(domain string, domainType uint16, list *structs.TypeList) *structs.DNSRecord {
list.Mutex.RLock()
defer list.Mutex.RUnlock()
domain = RegexpLastDash.ReplaceAllString(strings.Trim(domain, " "), "")
if len([]rune(domain)) == 0 {
return nil
}
if val, ok := list.List[domain]; ok {
return val
}
if domainType == dns.TypeA || domainType == dns.TypeAAAA {
if val, ok := list.List[fmt.Sprintf(`%s_%d`, domain, dns.TypeCNAME)]; ok {
return val
}
}
if val, ok := list.List[fmt.Sprintf(`%s_%d`, domain, domainType)]; ok {
return val
}
return nil
}
Вообще ничего сложного. Просто ищем в списках не забывая накладывать блокировку, иначе программа «вылетить» из-за конкурентного доступа.
Добавление в кэш
Я покажу только часть кода, так как остальной год достаточно просто дописать самостоятельно. В нем присутствует немного магии, но она не сложная. И так:
func AddInCache(msg *dns.Msg) {
CacheDomain.Mutex.Lock()
defer CacheDomain.Mutex.Unlock()
if len(msg.Answer) > 0 {
for _, answer := range msg.Answer {
AddInCacheLine(answer)
}
}
if len(msg.Extra) > 0 {
for _, extra := range msg.Extra {
AddInCacheLine(extra)
}
}
}
func AddInCacheLine(answer dns.RR) {
var msgi interface{} = answer
switch v := (msgi).(type) {
case *dns.A:
domain := RegexpLastDash.ReplaceAllString(strings.Trim(v.Hdr.Name, " "), "")
if dnsRecord := CacheDomain.List[fmt.Sprintf(`%s_%d`, domain, v.Hdr.Rrtype)]; dnsRecord != nil {
// Добавим адрес в существующую запись
// Найдем есть ли этот адрес уже в записи
if dnsRecord.Addr != nil {
if len(*dnsRecord.Addr) == 0 {
var a structs.Addr
a.Addr = v.A
a.Ttl = time.Now().Local().Add(time.Second * time.Duration(v.Hdr.Ttl))
dnsRecord.Addr = &[]structs.Addr{a}
} else {
search := false
for i := range *dnsRecord.Addr {
if net.IP.Equal((*dnsRecord.Addr)[i].Addr, v.A) {
search = true
(*dnsRecord.Addr)[i].Ttl = time.Now().Local().Add(time.Second * time.Duration(v.Hdr.Ttl))
}
}
if !search {
var a structs.Addr
a.Addr = v.A
a.Ttl = time.Now().Local().Add(time.Second * time.Duration(v.Hdr.Ttl))
addrs := *dnsRecord.Addr
addrs = append(addrs, a)
dnsRecord.Addr = &addrs
}
}
} else {
var a structs.Addr
a.Addr = v.A
a.Ttl = time.Now().Local().Add(time.Second * time.Duration(v.Hdr.Ttl))
dnsRecord.Addr = &[]structs.Addr{a}
}
} else {
dnsRecord := structs.DNSRecord{
Name: domain,
Type: v.Hdr.Rrtype,
Class: v.Hdr.Class,
Addr: &[]structs.Addr{
{
Addr: v.A,
Ttl: time.Now().Local().Add(time.Second * time.Duration(v.Hdr.Ttl)),
},
},
}
CacheDomain.List[fmt.Sprintf(`%s_%d`, dnsRecord.Name, dnsRecord.Type)] = &dnsRecord
}
}
}
Собственно нужно реализовать конструкцию switch для разных типов записей. Кто знает более элегантный способ, пожалуйста, не стесняйтесь.
Ответы
Формирование ответов тоже достаточно просто получается. В функции нужно все так же дописать конструкцию switch. Главное сам принцип:
func PrepareMessage(req *dns.Question, res *structs.DNSRecord) (lines []dns.RR) {
// Нашли в кэше. Сразу отдадим клиенту
head := dns.RR_Header{
Name: req.Name,
Rrtype: req.Qtype,
Class: req.Qclass,
}
now := time.Now().Local()
switch res.Type {
case dns.TypeA:
for _, addr := range *res.Addr {
if addr.Ttl.After(now) {
line := &dns.A{
Hdr: head,
A: addr.Addr,
}
line.Hdr.Ttl = uint32(addr.Ttl.Sub(now) / time.Second)
line.Hdr.Rrtype = dns.TypeA
line.Hdr.Name = fmt.Sprintf("%s.", res.Name)
lines = append(lines, line)
}
}
}
return
}
И это все.
Списочки
Эта вся работа учитывает и черный список и белый список. Если с черным списком все понятно, то вот с белым хотелось бы определиться. В итоге я решил, что это будут записи как в самом обычном DNS-сервере.
Как это быдет работать? Все просто! Если мы добавим нужную запись нужного типа в белый список, то мы его будем хранить все время. Получится такой мини-dns для домашних собственных нужд. Чтобы такое реализовать нужно подумать над форматами.
Для черного списка все элементарно: просто одна строка — один домен. Для белого списка будет определенный формат:
<домен> <тип записи> [приоритет целевого хоста] [вес записи] [порт] <адрес>
где:
Домен — доменное имя
Тип записи — A, AAAA, CNAME, DNAME, MX, SRV
Приоритет целевого хоста — приоритет хоста для записи SRV. Более низкое значение имеет более высокий приоритет
Вес записи — относится к типу записи MX и SRV
Порт — порт TCP или UDP, на котором работает сервис
Адрес — конечный адрес
Все TTL устанавливаются по умолчанию 600.
Списки необходимо загрузить. Черный список:
func LoadBlacklist() {
if _, err := os.Stat("database/blacklist.txt"); errors.Is(err, os.ErrNotExist) {
return
}
file, err := os.Open("database/blacklist.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
str := strings.Trim(scanner.Text(), " ")
if len([]rune(str)) > 0 {
if ([]rune(str))[0] != '#' {
domain := strings.ToLower(strings.Trim(str, " "))
BlackList.List[domain] = &structs.DNSRecord{}
}
}
}
}
Вообще ничего сложного! Просто читаем построчно и добавляем в список.
Теперь белый список:
func LoadWhitelist() {
if _, err := os.Stat("database/whitelist.txt"); errors.Is(err, os.ErrNotExist) {
return
}
file, err := os.Open("database/whitelist.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
strs := strings.Split(PrepareStr.ReplaceAllString(strings.Trim(scanner.Text(), " "), " "), " ")
if len(strs) >= 3 {
strs[0] = strings.ToLower(strs[0])
strs[1] = strings.ToUpper(strs[1])
strs[2] = strings.ToLower(strs[2])
if len(strs) == 4 {
strs[3] = strings.ToLower(strs[3])
}
switch strs[1] {
case "A":
dnsRecord := structs.DNSRecord{
Name: strs[0],
Type: dns.TypeA,
Addr: &[]structs.Addr{
{
Addr: net.ParseIP(strs[2]),
Ttl: time.Now().Local().Add(time.Second * time.Duration(600)),
},
},
}
WhiteList.List[fmt.Sprintf(`%s_%d`, strs[0], dns.TypeA)] = &dnsRecord
default:
err := fmt.Errorf(`unknow type record: %s`, strs[1])
panic(err)
}
}
}
}
Немного сложнее, но не на много. Здесь так же нужно дописать switch для требуемых типов записей.
Сервер готов.
Да, это простейший сервер, который не учитывает очень многое. Во всяком случае я его писал для своего домашнего сервера и он у меня работает.
Не спорю, в нем нет интерфейса, хотелось бы прикрутить REST API для работы, да еще и перезагружать нужно, чтобы перечитать списки. Минус еще в том, что при перезагрузке теряется кэш. Да, его хранить нет смысла, но при перезапуске клиенты перестают работать пока сервер снова не включится. В общем есть над чем работать, а пока полный исходный код находится в git-е.