И так, DNS-сервер у нас есть. Теперь не плохо было бы им управлять. Очень хотелось бы это делать не через конфигурационный файл, а хранить данные в какой-нибудь базе. Но теперь нужно придумать как с ней взаимодействовать. Для этих целей можно использовать REST API. Смысл заключается в том, что мы может отправлять запрос через HTTP-протокол и получать какой-то результат.
Требования
Если немного пофантазировать, то база должна быть не нагружена, в противном случае может произойти просадка производительности. В данном случае диски у нас не такие большие (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-репозитории.