Давно хотел решить проблему с рекламой. Наиболее простым способом сделать это на всех устройствах оказалось поднятие своего DNS сервера с блокированием запросов на получений IP адресов рекламных доменов.

Первым делом я начал с использования dnsmasq, но мне захотелось грузить списки из интернета и получать какую-нибудь статистику по использованию. Поэтому я и решил писать свой сервер.

Конечно, он написан не полностью с нуля, вся работа с DNS взята из этой библиотеки.

Конфигурация


Работать программа начинает, конечно же, с загрузки конфигурационного файла. Сразу подумал о необходимости автоматической подгрузки конфига при его изменении дабы избежать рестарта сервера. Для этого пригодился пакет fsnotify.

Структура конфига:

type Config struct {
	Nameservers    []string      `yaml:"nameservers"`
	Blocklist      []string      `yaml:"blocklist"`
	BlockAddress4  string        `yaml:"blockAddress4"`
	BlockAddress6  string        `yaml:"blockAddress6"`
	ConfigUpdate   bool          `yaml:"configUpdate"`
	UpdateInterval time.Duration `yaml:"updateInterval"`
}

Тут самым интересным моментом является слежение за обновлениями файла конфигурации. С помощью библиотеки делается это довольно просто: мы создаем Watcher, цепляем к нему файл и слушаем события из канала. True Go!

Код
func configWatcher() {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		log.Fatal(err)
	}
	defer watcher.Close()

	err = watcher.Add(*configFile)
	if err != nil {
		log.Fatal(err)
	}

	for {
		select {
		case event := <-watcher.Events:
			if event.Op&fsnotify.Write == fsnotify.Write {
				log.Println("Config file updated, reload config")
				c, err := loadConfig()
				if err != nil {
					log.Println("Bad config: ", err)
				} else {
					log.Println("Config successfuly updated")
					config = c
					if !c.ConfigUpdate {
						return
					}
				}
			}
		case err := <-watcher.Errors:
			log.Println("error:", err)
		}
	}
}


BlackList


Конечно, по-скольку целью стоит блокировка неугодных сайтов, то их необходимо где-то хранить. Для этого при небольшой нагрузке подойдет простая хэш-таблица пустых структур, где в качестве ключа используется блокируемый домен. Хочу заметить, что необходимо наличие точки на конце.
Но так-как у нас нет одновременного read/write, то можно обойтись без мьютексов.

Код
type BlackList struct {
	data map[string]struct{}
}

func (b *BlackList) Add(server string) bool {
	server = strings.Trim(server, " ")
	if len(server) == 0 {
		return false
	}

	if !strings.HasSuffix(server, ".") {
		server += "."
	}
	b.data[server] = struct{}{}

	return true
}

func (b *BlackList) Contains(server string) bool {
	_, ok := b.data[server]
	return ok
}


Кэширование


Изначально я думал обойтись без него, все-таки все мои устройства не создают существенного количества запросов. Но в один прекрасный вечер мой сервер каким-то образом обнаружили и начали флудить его одним и тем же запросом с частотой ~ 100 rps. Да, это немного, но ведь запросы проксируются на реальные namespace-сервера (в моем случае Google) и было бы очень неприятно получить блокировку.

Основной проблемой кэширования является большое количество различных запросов и их нужно хранить отдельно, поэтому получилась двухуровневая хеш-таблица.

Код
type Cache interface {
	Get(reqType uint16, domain string) dns.RR
	Set(reqType uint16, domain string, ip dns.RR)
}

type CacheItem struct {
	Ip dns.RR
	Die time.Time
}

type MemoryCache struct {
	cache map[uint16]map[string]*CacheItem
	locker sync.RWMutex
}

func (c *MemoryCache) Get(reqType uint16, domain string) dns.RR {
	c.locker.RLock()
	defer c.locker.RUnlock()

	if m, ok := c.cache[reqType]; ok {
		if ip, ok := m[domain]; ok {
			if ip.Die.After(time.Now()) {
				return ip.Ip
			}
		}
	}

	return nil
}

func (c *MemoryCache) Set(reqType uint16, domain string, ip dns.RR) {
	c.locker.Lock()
	defer c.locker.Unlock()

	var m map[string]*CacheItem

	m, ok := c.cache[reqType]
	if !ok {
		m = make(map[string]*CacheItem)
		c.cache[reqType] = m
	}

	m[domain] = &CacheItem{
		Ip: ip,
		Die: time.Now().Add(time.Duration(ip.Header().Ttl) * time.Second),
	}
}


Обработчик


Конечно, основной частью программы является обработчик входящих запросов, поэтому я его оставил на десерт. Основная логика примерно такая: мы получаем запрос, проверяем его наличие в блек-листе, проверяем наличие в кэше, проксируем запрос на реальные сервера.

Основной интерес представляет функция лукапа. В ней мы отправляем одновременно запрос сразу на все сервера (если успеем до прихода ответа) и ждем успешного ответа хотя бы от одного из них.

Код
func Lookup(req *dns.Msg) (*dns.Msg, error) {
	c := &dns.Client{
		Net:          "tcp",
		ReadTimeout:  time.Second * 5,
		WriteTimeout: time.Second * 5,
	}

	qName := req.Question[0].Name

	res := make(chan *dns.Msg, 1)
	var wg sync.WaitGroup
	L := func(nameserver string) {
		defer wg.Done()
		r, _, err := c.Exchange(req, nameserver)
		totalRequestsToGoogle.Inc()
		if err != nil {
			log.Printf("%s socket error on %s", qName, nameserver)
			log.Printf("error:%s", err.Error())
			return
		}
		if r != nil && r.Rcode != dns.RcodeSuccess {
			if r.Rcode == dns.RcodeServerFailure {
				return
			}
		}
		select {
		case res <- r:
		default:
		}
	}

	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()

	// Start lookup on each nameserver top-down, in every second
	for _, nameserver := range config.Nameservers {
		wg.Add(1)
		go L(nameserver)
		// but exit early, if we have an answer
		select {
		case r := <-res:
			return r, nil
		case <-ticker.C:
			continue
		}
	}

	// wait for all the namservers to finish
	wg.Wait()
	select {
	case r := <-res:
		return r, nil
	default:
		return nil, errors.New("can't resolve ip for" + qName)
	}
}


Метрики


Для метрики будем использовать клиент от prometheus. Используется он очень просто, сначала необходимо объявить счетчик, затем его зарегистрировать и в нужном месте вызвать метод Inc(). Главное не забыть запустить вебсервер с prometheus handler, чтобы он смог считывать метрики.

Код
var (
       totalRequestsTcp = prometheus.NewCounter(prometheus.CounterOpts(prometheus.Opts{
		Namespace: "dns",
		Subsystem: "requests",
		Name:      "total",
		Help:      "total requests",

		ConstLabels: map[string]string{
			"type": "tcp",
		},
	}))
)

func runPrometheus() {
	prometheus.MustRegister(totalRequestsTcp)

        http.Handle("/metrics", promhttp.Handler())
	log.Fatal(http.ListenAndServe(":9970", nil))
}


Думаю main не нуждается в представлении и описании. В данной статье код представлен в сокращенном формате

Полный код можно посмотреть в репозитории (конечно же приветствуются фиксы и дополнения). Также в репозитории есть файл для Docker и примерная конфигурация CI для Gitlab.

Спасибо за внимание.

Комментарии (15)


  1. rtzra
    05.02.2018 05:01
    +1

    Есть такая штука: pi-hole.net и куча blacklist для нее, например вот тут Убер-список на 1.4 млн доменов: raw.githubusercontent.com/CamelCase11/UnifiedHosts/master/hosts.all
    Я еще использую список «Домены, замеченные в майнинге криптовалют на своих сайтах»:
    github.com/Marfjeh/coinhive-block/blob/master/domains
    Еще есть скрипт StopAD для Mikrotik stopad.kplus.pro — там тоже есть интересные стоп-листы.


    1. WebProd Автор
      05.02.2018 11:16
      +1

      Спасибо за списки, добавлю их к своему


  1. Shtucer
    05.02.2018 10:57

    И на сколько этот прокси — прокси? Каким образом на него направляются запросы?


    1. WebProd Автор
      05.02.2018 10:59

      указываешь в настройках сети в качестве DNS-сервера


  1. Revertis
    05.02.2018 12:06

    Что-то мне подсказывает, что кэширование не поможет от DNS Amplification, и ваш сервер (если доступен по интернету) будет участвовать в DDoS.


    1. WebProd Автор
      05.02.2018 12:18

      Тут Вы правы, но кэширование было для борьбы с не нужными запросами на реальный DNS.


  1. bullgare
    05.02.2018 21:52

    Но так-как у нас нет одновременного read/write, то можно обойтись без мьютексов.

    RWMutex — это, вроде бы, не больно. Почему решили не использовать?


    1. WebProd Автор
      05.02.2018 22:06

      Исходя из архитектуры приложения надобность в любом мьютексе отсутствует. С хеш-таблицой производятся только операции чтения. Обновление списка производится заменой указателя на новую таблицу.


      1. bullgare
        05.02.2018 22:43

        github.com/GoWebProd/goDNS/blob/master/src/server/handlers.go#L40 — тут чтение
        github.com/GoWebProd/goDNS/blob/master/src/server/black.go#L87 — тут запись.
        Я не буду разводить нудятину про глобальные переменные, но про happens before тут есть что почитать — golang.org/ref/mem.
        Теоретически же (в зависимости от компилятора) может быть так, что хэндлер вообще не будет видеть обновлённые данные.


        1. WebProd Автор
          05.02.2018 22:50

          А, я имел ввиду другое место. Тем не менее ничего страшного, если хэндлер не сразу прочитает обновление. Я не считаю это критичным, но буду рад, если Вы объясните мне если я не прав.


          1. zelenin
            06.02.2018 02:08

            Тем не менее ничего страшного, если хэндлер не сразу прочитает обновление

            и запаникует


            1. WebProd Автор
              06.02.2018 10:18

              Почему же? Там всегда есть корректный указатель, старый или новый. Начальный задается еще до старта сервера: https://github.com/GoWebProd/goDNS/blob/master/src/server/main.go#L78


              1. gchebanov
                06.02.2018 13:52
                +2

                type T struct {
                	msg string
                }
                
                var g *T
                
                func setup() {
                	t := new(T)
                	t.msg = "hello, world"
                	g = t
                }
                
                func main() {
                	go setup()
                	for g == nil {
                	}
                	print(g.msg)
                }

                Тут в переменной g тоже всегда корректный указатель, но это не мешает «there is no guarantee that it will observe the initialized value for g.msg».
                Другими словами оптимизатор может вначале выполнить config.go:66, заполнив указатель новым свежим конфигом, и только потом заполнить этот конфиг значениями, т.к он не знает что кто-то может этот самый конфиг читать. Т.е по факту может случиться так что часть полей конфига уже новые, а часть — какие угодно. Вроде бы паники тут быть нигде быть не должно.


                1. WebProd Автор
                  06.02.2018 13:53
                  -1

                  Понял, спасибо. Да, не гарантируется, но к счастью с таким пока не сталкивался, но буду иметь в виду.


          1. bullgare
            06.02.2018 17:29

            package main
            
            import (
                "math/rand"
                "sync"
                "testing"
            )
            
            const (
                N = 100000
            )
            
            var (
                a []int
            )
            
            func TestRace(*testing.T) {
                a = []int{1, 2, 3}
                var wg sync.WaitGroup
                wg.Add(2)
                go func() {
                    for i := 0; i < N; i++ {
                        a = []int{rand.Int()}
                    }
                    wg.Done()
                }()
                go func() {
                    for i := 0; i < N; i++ {
                        _ = a
                    }
                    wg.Done()
                }()
                wg.Wait()
            }


            go test -race


            Попробуйте