DNS-сервер своими руками

Я писал коротко о DNS-сервере и какие типы записей бывают. На самом деле это достаточно сложная система, чтобы о ней так просто говорить. Но мы же храбрые люди и не боимся велосипедов! Попробуйем сделать 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-е.

Поделиться
Вы можете оставить комментарий, или ссылку на Ваш сайт.

Оставить комментарий

Вы должны быть авторизованы, чтобы разместить комментарий.