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

И так, DNS-сервер у нас есть. Теперь не плохо было бы им управлять. Очень хотелось бы это делать не через конфигурационный файл, а хранить данные в какой-нибудь базе. Но теперь нужно придумать как с ней взаимодействовать. Для этих целей можно использовать REST API. Смысл заключается в том, что мы может отправлять запрос через HTTP-протокол и получать какой-то результат.

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

Требования

Если немного пофантазировать, то база должна быть не нагружена, в противном случае может произойти просадка производительности. В данном случае диски у нас не такие большие (SD-карта), а если посмотреть по предыдущей статье, то список блокировки примерно из 100 тысяч записей занимает не очень много памяти (в моем случае около 25МБ). Возьмем SQLite. Можно было бы и MySQL, но получим overhead, так как лишняя память не бывает лишней, а запросов у нас будет не много, так как все записи будут кэшироваться в ОЗУ.

Вторая проблема, которую не плохо было бы решить — это аутентификация. В самом вводе пароля ничего такого криминального нет, но вот сессии где-то надо хранить (ОЗУ, диск, другой сервер и т. д.). Опять же это накладывает определенные ресурсы (мы же экономисты для одноплатника). По сути для домашнего сервера нам нужно просто защититься паролем и для выполнения операций проверять что мы авторизированы. Возьмем JWT. Не требует хранения на сервере, а ключ сессии будет передаваться с основным запросом.

База данных

Для начала подготовим функции работы с БД (db/db.go):


package db

import (
	"dns-adblock/db/models"
	"log"
	"os"
	"time"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

var DB *gorm.DB

func Connect() {
	dsnSQLite := "database/database.db"
	logLevel := logger.Info

	var err error
	newLogger := logger.New(
		log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
		logger.Config{
			SlowThreshold: time.Second, // Slow SQL threshold
			LogLevel:      logLevel,    // Log level
			Colorful:      false,       // Disable color
		},
	)
	newLogger.LogMode(logger.Info)
	DB, err = gorm.Open(sqlite.Open(dsnSQLite), &gorm.Config{
		Logger:                                   newLogger,
		DisableForeignKeyConstraintWhenMigrating: true,
	})
	if err != nil {
		panic(err)
	}

	// Установим пул соединений
	dbSettings, err := DB.DB()
	if err != nil {
		panic(err)
	}
	dbSettings.SetMaxIdleConns(10)
	dbSettings.SetMaxOpenConns(50)
	dbSettings.SetConnMaxLifetime(time.Minute * 10)

	// Создание БД

	DB.AutoMigrate(
		&models.Blacklist{},
		&models.Whitelist{},
	)
}

После нам понадобятся модели в директории db/models.

base.go:


package models

import (
	"time"

	uuid "github.com/satori/go.uuid"
	"gorm.io/gorm"
)

type Base struct {
	ID        uuid.UUID `gorm:"type:uuid;primary_key" json:"id"`
	CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
	UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
	DeletedAt time.Time `gorm:"column:deleted_at;index" json:"deletedAt"`
}

func (base *Base) BeforeCreate(scope *gorm.DB) (err error) {
	if base.ID == uuid.Nil {
		base.ID = uuid.NewV4()
	}
	return nil
}

whitelist.go:


package models

type Whitelist struct {
	Base
	Domain   string  `gorm:"column:domain;type:varchar;size:255" json:"domain"`
	Type     uint16  `gorm:"column:record_type" json:"type"`
	Priority *uint16 `gorm:"column:priority" json:"priority"`
	Weight   *uint16 `gorm:"column:weight" json:"weight"`
	Port     *uint16 `gorm:"column:port" json:"port"`
	Address  *string `gorm:"column:address;type:varchar;size:255" json:"addr"`
}

blacklist.go:


package models

type Blacklist struct {
	Base
	Domain string `gorm:"column:domain;type:varchar;size:200"`
}

Здесь должно быть все предельно просто и понятно.

Теперь в описание типов структур добавим еще одну:


type DNSRecordWeb struct {
	Domain string `json:"domain"`
	Type   uint16 `json:"type"`

	//Addrs *[]struct {
	Addr     *string `json:"addr"`
	Priority *uint16 `json:"priority"`
	Weight   *uint16 `json:"weight"`
	Port     *uint16 `json:"port"`
	//} `json:"addrs"`
}

WEB-сервер

Для работы HTTP-сервера я использую фреймворк Echo. Как в нем использовать JWT читайте в документации.

Весь код я сюда выводить не буду, а только основные моменты.

Чтобы получить список доменов, нужно создать такую функцию:


func list(c echo.Context) error {
	libs.BlackList.Mutex.RLock()
	defer libs.BlackList.Mutex.RUnlock()

	len := len(libs.BlackList.List)
	res := make([]string, len)
	i := 0
	for key := range libs.BlackList.List {
		res[i] = key
		i++
	}
	return c.JSON(
		http.StatusOK,
		res,
	)
}

Добавление записи в БД особо не сложнаю, просто объемная:


func add(c echo.Context) error {
	libs.BlackList.Mutex.Lock()
	defer libs.BlackList.Mutex.Unlock()

	var newDnsRecord struct {
		Domain string `json:"domain"`
	}
	err := json.NewDecoder(c.Request().Body).Decode(&newDnsRecord)
	if err != nil {
		return c.JSON(http.StatusBadRequest, structs.Result{
			Result:  false,
			Code:    http.StatusBadRequest,
			Message: &[]string{"Error request"}[0],
		})
	}

	domain := strings.ToLower(libs.PrepareStr.ReplaceAllString(libs.RegexpLastComma.ReplaceAllString(libs.PrepareStr.ReplaceAllString(newDnsRecord.Domain, ""), ""), ""))
	if domain != "" {
		if _, ok := libs.BlackList.List[domain]; !ok {

			var err error
			tx := db.DB.Begin()

			defer func() {
				libs.EndTransaction(tx, err)
			}()

			if res := tx.Create(&models.Blacklist{
				Domain: domain,
			}); res.RowsAffected == 0 {
				err = res.Error
				return c.JSON(
					http.StatusInternalServerError,
					structs.Result{
						Code:    http.StatusInternalServerError,
						Result:  false,
						Message: &[]string{res.Error.Error()}[0],
					},
				)
			}

			libs.BlackList.List[domain] = &structs.DNSRecord{}

			return c.JSON(
				http.StatusOK,
				structs.Result{
					Code:   http.StatusOK,
					Result: true,
				})
		}
	}
	return c.JSON(
		http.StatusConflict,
		structs.Result{
			Code:   http.StatusConflict,
			Result: false,
		})
}

Удаление записи тоже ничего сверхъестестенного не несет:


func remove(c echo.Context) error {
	libs.BlackList.Mutex.Lock()
	defer libs.BlackList.Mutex.Unlock()
	domain := strings.ToLower(libs.PrepareStr.ReplaceAllString(libs.RegexpLastComma.ReplaceAllString(libs.PrepareStr.ReplaceAllString(c.Param("domain"), ""), ""), ""))

	if domain != "" {
		if _, ok := libs.BlackList.List[domain]; ok {
			var err error
			tx := db.DB.Begin()

			defer func() {
				libs.EndTransaction(tx, err)
			}()

			if res := tx.Delete(&models.Blacklist{}, "domain = ?", domain); res.RowsAffected == 0 {
				err = res.Error
				return c.JSON(
					http.StatusInternalServerError,
					structs.Result{
						Code:    http.StatusInternalServerError,
						Result:  false,
						Message: &[]string{res.Error.Error()}[0],
					},
				)
			}
			delete(libs.BlackList.List, domain)
			return c.JSON(
				http.StatusOK,
				structs.Result{
					Code:   http.StatusOK,
					Result: true,
				})
		}
	}
	return c.JSON(
		http.StatusNotFound,
		structs.Result{
			Code:   http.StatusNotFound,
			Result: false,
		})
}

Тут главное не забывать не только работать с базой данных, но и обновлять массив, чтобы изменения сразу применялись.

Это было для черного списка. Белый список реализуется по такому же принципу. Тут добавляется проверка типа записи и полей немного больше, а так все то же самое.

Загрузка данных

При запуске сервера необходимо загружать записи из БД. Для этого в функцию LoadBlacklist добавим небольшой кусок кода:


// Загружаем из БД
tx := db.DB.Begin()

defer func() {
    EndTransaction(tx, err)
}()

var domains []models.Blacklist

if res := tx.Find(&domains); res.RowsAffected > 0 {
    // Есть список в БД. Добавим в список в ОЗУ
    for _, domain := range domains {
        if _, ok := BlackList.List[domain.Domain]; !ok {
            BlackList.List[domain.Domain] = &structs.DNSRecord{}
        }
    }
}

Для белого списка все аналогично, только, опять же, кода немного больше из-за проверки типов записей.

Запускаем сервер

Теперь нужно запустить сервер, чтобы он мог нам отвечать. Для этого в файл mail.go добавим немного кода. В функцию init():


e = echo.New()

// Middleware
middlewares.Init(e)

// Routes
routes.Route(e)

И в main():


go func() {
    e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", libs.Config.WebPort)))
}()

Не забываем добавить просмотр некоторых переменных окружения:


if tmp, exists = os.LookupEnv("WEBPORT"); exists {
    Config.WebPort, err = strconv.Atoi(tmp)
    if err != nil {
        panic(err)
    }
} else {
    Config.Port = 3000
}

if tmp, exists = os.LookupEnv("FILL_EXTRA"); exists {
    if tmp == "true" || tmp == "1" {
        Config.FillExtra = true
    }
}

if Config.Password, exists = os.LookupEnv("PASSWORD"); !exists {
    alphabet := "ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890!@#$%*&"

    Config.Password = ""

    for i := 0; i < 20; i++ {
        Config.Password += string([]rune(alphabet)[rand.Intn(len(alphabet))])
    }
}

Немного пояснений

Чтобы воспользоваться функциями необходимо сначала аутентифицироваться. Для этого выполняем POST-запрос по пути http://localhost:3000/api/login в формате JSON со следующей структурой:


{
    "password": "{{password}}"
}

Если пароль верный, то в ответ получим токен. Далее берем этот токен и к остальным запросам в заголовок Authorization добавляем значение "Bearer {{token}}" и передаем параметры запроса.

Заключение, но не конец

Теперь можно делать автоматизацию управления записями с помощью, например, curl+bash+jq.

Весь исходный код доступен в git-репозитории.

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

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

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