Хранение зон named в MySQL

в 16:03, , рубрики: DNS, mysql, named, php, метки: , ,

Давно порывался найти какое-либо бесплатное и толковое решение для хранения доменных зон в базе данных, и управлять всем этим с лёгкостью. В интернете безумное множество решений, начиная от бесплатных, заканчивая платными и дорогими. Но, к сожалению ни одно из них не оправдало моих надежд. Какие-то продукты были кривые и не управляемые, какие-то не могли использоваться для чего-либо ещё. В конечном итоге нашёл время и написал свой скрипт, который грузит данные о домене и записывает их в фай для named.

Скрипт написан на PHP и пусть кто-то говорит, что данный язык программирования и языком назвать сложно, но он работает, а это главное! Будем считать, что у Вас уже есть машина, где крутится сервер имён, установлен PHP и база данных MySQL. Я писал структуру базы данных не только для named, но и для использования другими программами, такими как, dovecot, postfix, ftp и т.д. В конечном итоге должна будет получится панелька управления хостингом, но пока напишу только о named.
И так структура таблицы:

CREATE TABLE IF NOT EXISTS `bill_domains` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`client_id` int(10) unsigned NOT NULL DEFAULT '0',
`domain` varchar(150) COLLATE utf8_unicode_ci NOT NULL DEFAULT '',
`status` int(1) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`),
KEY `client_id` (`client_id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1;

INSERT INTO `bill_domains` (`id`,`client_id`,`domain`,`status`) VALUES ('1','1','mydomain.ru','1');

CREATE TABLE IF NOT EXISTS `bill_dns` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `client_id` int(10) unsigned NOT NULL DEFAULT '0',
  `domain_id` int(10) unsigned NOT NULL DEFAULT '0',
  `name` varchar(75) COLLATE utf8_unicode_ci NOT NULL DEFAULT '',
  `type` enum('A','AAAA',’CNAME’,’PTR’,'NS','MX','TXT','SPF') COLLATE utf8_unicode_ci NOT NULL DEFAULT 'A',
  `data` varchar(150) COLLATE utf8_unicode_ci NOT NULL DEFAULT '',
  `aux` int(10) unsigned NOT NULL DEFAULT '0',
  `status` int(1) NOT NULL DEFAULT '1',
  PRIMARY KEY (`id`),
  KEY `client_id` (`client_id`),
  KEY `domain_id` (`domain_id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1;
INSERT INTO `bill_dns` (`id`, `client_id`, `domain_id`, `name`, `type`, `data`, `aux`, `status`) VALUES
(1, 1, 1, 'www', 'A', '192,168.1.1', 0, 1);

CREATE TABLE IF NOT EXISTS `bill_dns_log` (
  `domain_id` int(10) NOT NULL,
  `time` int(10) NOT NULL,
  `flag` int(1) NOT NULL DEFAULT '0',
  PRIMARY KEY (`domain_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

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

После того как мы создали три таблицы, у нас есть куда сложить сами домены, записи типа: А, АААА, NS, CNAME, MX, PTR, SPF и таблица, в которой будут записи с индексом домена, отпечатком времени, когда были сделаны изменения и флаг, сообщающий нам, применились ли изменения.

Дальше мы будем писать на PHP.

Файл для соединения с базой данных и работой с данными целиком приводить не стану, только лишь опишу методы, с которыми будет связана дальнейшая работа.
MySQL.php:

class mysql {
	// логин к ДБ
	var $sql_login;
	// пароль к ДБ
	var $sql_passwd;
	// имя ДБ
	var $sql_database;
	// MySQL хост
	var $sql_host;
	// MySQL идентификатор соединения
	var $con_id;
	// соединяемся с ДБ
	function connect() {
		$this->con_id = mysql_connect($this->sql_host,$this->sql_login,$this->sql_passwd);
		mysql_select_db($this->sql_database,$this->con_id);
		mysql_query("SET NAMES `utf-8`;",$this->con_id);
		mysql_query("SET CHARSET utf-8;",$this->con_id);
		if (!$this->con_id){
			die("Not connected");
		}
	}
	// Выполняем запрос
	function query($query, $file = false, $line = false) {
		if ( !$query ) {
			die("Нет запроса для выполнения");
		} else {
			$r = mysql_query($query,$this->con_id) or trigger_error(mysql_error());
		}
		// Если запрос предназначен для вставки данных возвращаем ИД ряда, в противном случае возвращаем сам результат.
		return (preg_match("/^insert/i",$query) ? $this->l_id() : $r);
	}
	function listing($query, $file = false, $line = false){
		// получаем список записей по запросу и складываем их в нормальный массив.
		// если кому-то будет надо, поделюсь кодом этого файла.
		return $listing;
}
// получаем последний индекс от последнего INSERT запроса
function l_id() {
		return mysql_insert_id($this->con_id);
	}
	// закрываем соединение
	function close() {
		mysql_close($this->con_id);
	}

Целиком не стал приводить код этого файла т.к. он немного шире и используется не только в описываемом сценарии.

Файл настроек для работы скрипта:
config.php:

<?php
$dbuser = "…"; //имя пользователя для соединения с ДБ
$dbpass = "…"; //пароль для соединения с ДБ
$dbhost = "…"; // имя хосьа ДБ
$dbname = "…"; // имя самой ДБ
$dbpref = "…"; // Префикс названия таблиц, я стараюсь всегда использовать префиксы. Исторически так сложилось.

$timeout = 180; // кол-во секунд за которое будем смотреть изменения в ДБ
// Параметры для named
$global_ttl = 1200; // seconds. 1200 = 20 min; 1800 = 30 min; 3600 = 1h; 86400 = 24h;
$chroot_zone = "zones/"; // директория где будут лежать файлы зон
$zone_folder = dirname(__FILE__)."/zones/"; // абсолютный путь
$zone_config = dirname(__FILE__)."/zones.conf"; // конфигурационный файл

$server_name = "ns.mydomain.ru"; // имя сервера на котором запускается скрипт
$other_ns = array("ns3. mydomain.ru ","ns1. mydomain.ru ","ns2.mydomain.ru"); // список всех наших ДНС серверов
$mail_server = array(10=>"mail.mydomain.ru"); //приоритетный почтовый сервер для всех доменов, если не указан другой
$log_file = dirname(__FILE__)."/error.log"; // логфайл
$zone_master = "hostmaster"; // имя администратора зоны
// цифровые значения для зон. Могут быть разными. Я выбрал такие.
$refresh = 100;
$retry = 180;
$expire = 86400;
$negative = 3600;
?>

Теперь собственно то, ради чего писалась вся эта статья dns.php:

#!/usr/local/bin/php -f
<?php
// устанавливаем рабочую директорию
chdir( dirname( __FILE__ ) );
// грузим необходимые файлы
require_once "mysql.php";
require_once "config.php";

// функция для записи в лог файл
function logf($message, $file, $line){
	global $log_file;
	$handle = fopen($log_file, "a");
	fwrite($handle, "[".date("d.m.Y H:i:s")."] Error: ".$message." In file: ".$file." at ".$line."n");
	fclose($handle);
}
// функция создания файла зоны
function create_zone($zone){
	global $zone_folder;
	touch($zone_folder.$zone);
	return true;
}
// функция создания т.н. заголовка для файла зоны
function set_header($zone, $counter = 1){
	global $global_ttl, $server_name, $zone_master, $refresh, $retry, $expire, $negative;
	$header	= "$TTL ".$global_ttl.";n";
	$header .= $zone.".tINtSOAt".$server_name.".t".$zone_master.".".$zone.". (n";
	$header .= "tt".date("Ymd").sprintf("%02d",$counter)."; Serialn";
	$header .= "tt".$refresh."; Refresh periodn";
	$header .= "tt".$retry."; Retryn";
	$header .= "tt".$expire."; Expiren";
	$header .= "tt".$negative."; Negative cachingn";
	$header .= "t)n";
	return $header;
}
// функция для указания почтовых серверов для домена
function set_mx($servers = false){
	global $mail_server;
	$content = "";
	if (!$servers || !is_array($servers)){ // если небыло передано ни одного параметра, устанавливаем значение по умолчанию
		foreach ($mail_server as $k => $v){
			$content .= "tIN MX ".$k." ".$v.".n";
		}
	} else { // в противном случае, указываем что было передано в виде параметра, пример: $servers = array("10"=>"mx.google.com","20"=>"mx2.google.com")
		foreach ($servers as $k => $v){
			$content .= "tIN MX ".$k." ".$v.".n";
		}
	}
	return $content;
}
function set_ns($servers = false){ // определяем на каких ДНС серверах будет висеть наш домен. По сути всё тоже что и для почтового сервера.
	global $server_name, $other_ns;
	$content = "tIN NS ".$server_name.".n";
	if (!$servers || !is_array($servers)){
		for($i=0;$i<count($other_ns);$i++){
			$content .= "tIN NS ".$other_ns[$i].".n";
		}
	} else {
		for($i=0;$i<count($servers);$i++){
			$content .= "tIN NS ".$servers[$i].".n";
		}
	}
	return $content;
}
// функция записи всех данных в файл зоны
function write_zone($content, $zone){
	global $zone_folder;
	$handle = fopen($zone_folder.$zone, "w");
	fwrite($handle, $content);
	fclose($handle);
}

$parsed_zones = array(); // здесь будут все имена доменов которые на данный момент уже есть в конфиге zones.conf
$new_zones = array(); // список новых доменов
// парсим конфиг с существующими зонами
$handle = fopen($zone_config, "r");
$zone_content = "";
while (!feof($handle)){
	$zone_content .= fread($handle, 8192);
}
fclose($handle);
$lines = explode("n",$zone_content);
foreach($lines as $k => $v){
	if (preg_match("/zones"(w+)"/i",$v, $matches)){
		$parsed_zones[] = $matches[1];
	}
}
// соединяемся с ДБ
$db = new mysql();
$db->sql_login		= $dbuser;
$db->sql_passwd		= $dbpass;
$db->sql_database	= $dbname;
$db->sql_host			= $dbhost;
$db->connect();
define("DB_TABLE_PREFIX", $dbpref);

$fetch_all = false;

if (!file_exists($zone_folder)){ // если директория с файлами зон не найдена, пытаемся её создать
	$fetch_all = true; // и указываем, что будем выкачивать все зоны из ДБ
	if (!mkdir($zone_folder, 0644, true)){
		logf("Can not create directory ".$zone_folder, __FILE__, __LINE__);
		exit;
	}
}
if ($fetch_all){ // Выкачиваем все активные домены от активных клиентов
	$sql = "SELECT
						m.id,
						m.domain
					FROM `".DB_TABLE_PREFIX."domains` AS m
					LEFT JOIN `".DB_TABLE_PREFIX."clients` AS c ON (m.client_id = c.id)
					WHERE
						m.status = '1' and
						c.status = '1'
					ORDER BY m.domain ASC;";
	$list = $db->listing($sql, __FILE__, __LINE__); // получаем результат в виде массива
	if ($list){ // если массив не пустой и там есть домены, получаем список НС записей к каждому домену
		$c = count($list);
		for($i=0;$i<$c;$i++){
			$sql = "SELECT
								m.name,
								m.type,
								m.data,
								m.aux
							FROM `".DB_TABLE_PREFIX."dns` AS m
							WHERE
								m.status = '1' and
								m.domain_id = '".$list[$i]["id"]."'";
			$list[$i]["zones"] = $db->listing($sql, __FILE__, __LINE__);
		}

		for ($i=0;$i<$c;$i++){
			$ns_flag = false;
			$mx_flag = false;
			$mail = array();
			$ns = array();
			$source = "";
			if (!$list[$i]["zones"]) continue;
			if (!in_array($list[$i]["domain"], $parsed_zones)){
				$new_zones[] = $list[$i]["domain"];
			}
			// раскладываем все данные по полочкам, что бы понимать какие данные у домена уже есть
			$z = count($list[$i]["zones"]);
			for($zi=0;$zi<$z;$zi++){
				if ($list[$i]["zones"][$zi]["type"]=="MX"){ // если есть МХ запись, устанавливаем соответствующий флаг
					$mx_flag = true;
					$mail[$list[$i]["zones"][$zi]["aux"]] = $list[$i]["zones"][$zi]["data"];
				}
				if ($list[$i]["zones"][$zi]["type"]=="NS"){ // тоже что и МХ но для NS записей
					$ns_flag = true;
					$ns[] = $list[$i]["zones"][$zi]["data"];
				}
				if ( // здесь собираем остальные данные
					$list[$i]["zones"][$zi]["type"]=="A" || 
					$list[$i]["zones"][$zi]["type"]=="AAAA" || 
					$list[$i]["zones"][$zi]["type"]=="CNAME"
				){
					$source .= $list[$i]["zones"][$zi]["name"]."t".($list[$i]["zones"][$zi]["type"]=="CNAME" ? "CNAME" : "IN ".$list[$i]["zones"][$zi]["type"])." ".$list[$i]["zones"][$zi]["data"]."n";
				}
			}
			// ну и собственно, пишем данные в файл.
			$content = "";
			create_zone($list[$i]["domain"]);
			$content .= set_header($list[$i]["domain"], $i);
			$content .= set_ns($ns);
			$content .= set_mx($mail);
			$content .= "n";
			$content .= $source;
			write_zone($content, $list[$i]["domain"]);
		}
	} else {
		logf("No domain names for downloading", __FILE__, __LINE__);
	}
} else { // если директория с файлами зон существует, получаем только список доменов у которых были изменения. Всё остальное будет как описано выше, только для изменённоых доменов
	$sql = "SELECT
						m.domain_id,
						d.domain,
						d.id
					FROM `".DB_TABLE_PREFIX."dns_log` AS m
					LEFT JOIN `".DB_TABLE_PREFIX."domains` AS d ON (m.domain_id = d.id)
					LEFT JOIN `".DB_TABLE_PREFIX."clients` AS c ON (d.client_id = c.id)
					WHERE
						m.flag = '0' and
						m.time >= '".(time()-$timeout)."' and
						d.status = '1' and
						c.status = '1'
					ORDER BY d.domain ASC";
	$list = $db->listing($sql, __FILE__, __LINE__);
	if ($list){
		$c = count($list);
		for($i=0;$i<$c;$i++){
			$sql = "SELECT
								m.name,
								m.type,
								m.data,
								m.aux
							FROM `".DB_TABLE_PREFIX."dns` AS m
							WHERE
								m.status = '1' and
								m.domain_id = '".$list[$i]["id"]."'";
			$list[$i]["zones"] = $db->listing($sql, __FILE__, __LINE__);
		}

		for ($i=0;$i<$c;$i++){
			$ns_flag = false;
			$mx_flag = false;
			$mail = array();
			$ns = array();
			$source = "";
			if (!$list[$i]["zones"]) continue;
			if (!in_array($list[$i]["domain"], $parsed_zones)){
				$new_zones[] = $list[$i]["domain"];
			}
			$z = count($list[$i]["zones"]);
			for($zi=0;$zi<$z;$zi++){
				if ($list[$i]["zones"][$zi]["type"]=="MX"){
					$mx_flag = true;
					$mail[][$list[$i]["zones"][$zi]["aux"]] = $list[$i]["zones"][$zi]["data"];
				}
				if ($list[$i]["zones"][$zi]["type"]=="NS"){
					$ns_flag = true;
					$ns[] = $list[$i]["zones"][$zi]["data"];
				}
				if (
					$list[$i]["zones"][$zi]["type"]=="A" || 
					$list[$i]["zones"][$zi]["type"]=="AAAA" || 
					$list[$i]["zones"][$zi]["type"]=="CNAME"
				){
					$source .= $list[$i]["zones"][$zi]["name"]."t".($list[$i]["zones"][$zi]["type"]=="CNAME" ? "CNAME" : "IN ".$list[$i]["zones"][$zi]["type"])." ".$list[$i]["zones"][$zi]["data"]."n";
				}
			}
			$content = "";
			create_zone($list[$i]["domain"]);
			$content .= set_header($list[$i]["domain"], $i);
			$content .= set_ns($ns);
			$content .= set_mx($mail);
			$content .= "n";
			$content .= $source;
			write_zone($content, $list[$i]["domain"]);
			$sql = "UPDATE `".DB_TABLE_PREFIX."dns_log` SET `flag` = '1' WHERE `domain_id`='".$list[$i]["domain_id"]."'";
			$db->query($sql, __FILE__, __LINE__);
		}
	}
}
if (is_array($new_zones) && count($new_zones)>0){
	$handle = fopen($zone_config,"w");
	$new_array = array();
	foreach($parsed_zones as $k => $v){
		$new_array[] = $v;
	}
	foreach($new_zones as $k => $v){
		$new_array[] = $v;
	}
	natsort($new_array);
	$content = "";
	foreach($new_array as $k => $v){
		$content .= "zone "".$v."" {n";
		$content .= "ttype master;n";
		$content .= "tfile "".$chroot_zone.$v."";n";
		$content .= "};nn";
	}
	fwrite($handle, $content);
	fclose($handle);
}
shell_exec('rndc reload');
?>

В данном примере, я не стал пока описывать проверку записей типа SPF и углубляться в регулярные выражения, на предмет всевозможных уязвимостей, поскольку такого рода проверки у меня идут ещё на стадии добавления данных в ДБ. Именно по этой причине я уверен, что получаю оттуда проверенные данные, которые не могут нанести урон серверу.

Дальше устанавливаем chmod + x dns.php и проверяем наш скрипт.
Если в логфайле не появилось страшных надписей с угрозами, можем прописать его в кронтаб.
Поскольку, в конфиге у нас указано 180 секунд, а это 3 минуты, то запись в кроне должна быть примерно следующей

*/3 * * * * root /путь/до/dns.php

P.S. Чуть не забыл!
В самом конфиге named.conf, в самом конце ставим такую строку:
include "zones.conf";
Внутри этого файла, после срабатывания скрипта должны быть такого содержания строки:

zone «mydomain.ru» {
type master;
file «zones/maidomain.ru»;
};

Ну и собственно в файле с одноимённым названием будет конфиг нашей зоны:

$TTL 1200; 5 min

mydomain.ru. IN SOA ns.mydomain.ru. hostmaster.mydomain.ru. (
              2012070591 ; Serial
              300 ; refresh
              600 ; retry
              8640000 ; expire
              300) ; minimum

              IN NS ns.mydomain.ru.
              IN MX 10 mail.mydomain.ru.

www             IN A 192.168.1.1

Данная статья не претендует на какие-либо титулы, а рассчитана на тех, кто хочет хранить свои домены в ДБ у управлять ими не из консоли и в то же время не использовать не понятные, платные продукты.

Комментарии, вопросы и рекомендации приветствуются. Любые замечания по статье будут учтены.

Автор: intrade

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js