Пару недель назад к моему хорошему знакомому (по совместительству системному администратору в городском, но не очень крупном провайдере) коллеги из Роскомнадзора поставили на вид, что по IP он, конечно, блокирует запрещенные сайты, но по URL или доменным именам совершенно нет, а при смене IP адреса запрещенным ресурсом тот снова начинает открываться. Товарищ пожаловался мне на судьбу, заодно намекнув, что один раз лично его уже штрафовали за невыполнение требований печально известного № 193-ФЗ.
Схема подключений у данного провайдера следующая:
- Все пользователи (кроме купивших статический IP) находятся за NAT;
- NAT реализован как обычный forwarding на не менее обычном debian;
- На каждом NAT есть не менее обычный iptables;
- Есть два своих DNS-сервера, работающих на том же debian и PowerDNS.
После некоторых раздумий, а также гугления информации о layer 7 filtering на коленке, гугл нам сказал, что подобные вещи — прерогатива дорогого оборудования, a l7-filter для iptables перестал развиваться достаточно давно. После этого мы начали думать в сторону прокси-серверов. Со squid в промышленных масштабах ни один из нас не работал, однако было довольно много экспериментов с nginx — очень мощным продуктом Игоря Сысоева.
Общая схема работы была выработана следующая:
- iptables на NAT перехватывает все DNS-запросы и перенаправляет их на наш DNS-сервер;
- DNS-сервер держит зоны в том числе и запрещенных ресурсов, не пытаясь их обновить через recursor;
- Эти зоны единственным адресом данных ресурсов указывают адрес сервера с nginx.
Понятно, что конфигурацию nginx и список зон запрещенных ресурсов необходимо обновлять динамически: в требованиях Роскомнадзора указана частота 3 раза в сутки. Также очевиден и недостаток: доступ по HTTPS придется либо ограничивать, либо разрешать, но с использованием собственного сертификата, иначе трафик через наш nginx будет шифрован и свою основную функцию (фильтрацию) он осуществлять не сможет. Во втором варианте неизбежна ругань браузеров клиентов на неправильный сертификат. Более того, если под блокировку попадет адрес из списка доменов google, а у клиента установлен google chrome, то клиента на данные сайты не пустит в принципе. Но других наколенных вариантов мы в ограниченное время изобрести не смогли. Итак, что у нас получилось:
- Скрипт, загружающий список запрещенных сайтов (обычный XML, отдается SOAP-сервисом после соответствующего запроса. Необходима авторизация с помощью сертификата, уникального для каждого провайдера).
- Скрипт, загружающий список зон в DNS-сервера.
- Скрипт, создающий необходимую конфигурацию для nginx.
Первый скрипт я приводить не буду: провайдеры сами знают как получить информацию, а обычным пользователям ее разглашать не рекомендуется.
Второй скрипт (SQL для работы с базой PowerDNS):
USE pdns;
create temporary table if not exists dump(
domain text
);
TRUNCATE TABLE dump;
load data local infile '/opt/zapret/dump.xml' into table dump
LINES STARTING BY '<domain>' TERMINATED BY '</domain>'
(@tmp)
SET domain = ExtractValue(@tmp, '/');
create temporary table if not exists locked(
domain varchar(767) primary key
);
TRUNCATE TABLE locked;
INSERT INTO locked
SELECT DISTINCT domain FROM dump;
create temporary table if not exists locked1(
domain varchar(767) primary key
);
TRUNCATE TABLE locked1;
INSERT INTO locked1
SELECT * FROM locked;
DELETE FROM l
USING locked l
INNER JOIN locked1 l1 ON l.domain=SUBSTR(l1.domain from 5);
UPDATE locked
SET domain=SUBSTR(LCASE(domain) FROM 5)
WHERE LEFT(LCASE(domain), 4) = 'www.';
DELETE FROM locked
WHERE domain LIKE '%youtube.com'
OR domain LIKE '%google.com'
OR domain LIKE '%google.ru';
create temporary table if not exists old_locked(
id int,
domain varchar(767) primary key
);
TRUNCATE TABLE old_locked;
INSERT INTO old_locked
SELECT DISTINCT d.id, d.name as domain
FROM domains d
INNER JOIN records r ON d.id=r.domain_id
WHERE r.content='1.2.3.4' AND d.name NOT LIKE '%provider.ru';
INSERT INTO domains (name, master, last_check, type, notified_serial, account)
SELECT n.domain, NULL, NULL, 'NATIVE', NULL, NULL
FROM locked n
LEFT JOIN old_locked o ON n.domain=o.domain
WHERE o.domain IS NULL;
INSERT INTO records (domain_id, name, type, content, ttl, prio, change_date, ordername, auth)
SELECT d.id AS domain_id, d.name, 'SOA' as type, 'ns.provider.ru dns.provider.ru 2014022701 28800 7200 604800 86400' as content, 86400 as ttl, 0 as prio, 1393508792 as change_date, '' as ordername, 1 as auth
FROM domains d
INNER JOIN locked l ON d.name=l.domain
LEFT JOIN old_locked o ON d.name=o.domain
WHERE o.domain IS NULL;
INSERT INTO records (domain_id, name, type, content, ttl, prio, change_date, ordername, auth)
SELECT d.id AS domain_id, CONCAT('*.', d.name), 'A' as type, '1.2.3.4' as content, 86400 as ttl, 0 as prio, 1393508820 as change_date, '' as ordername, 1 as auth
FROM domains d
INNER JOIN locked l ON d.name=l.domain
LEFT JOIN old_locked o ON d.name=o.domain
WHERE o.domain IS NULL;
DELETE FROM r, d
USING records r
INNER JOIN domains d ON r.domain_id=d.id
INNER JOIN old_locked o ON d.name=o.domain
LEFT JOIN locked l ON o.domain=l.domain
WHERE l.domain IS NULL;
После исполнения данного скрипта надо не забыть выполнить
# pdnssec rectify-all-zones
для того, чтобы powerdns осознал изменения.
Третий скрипт (формирование списка blocked):
<?php
$xml = simplexml_load_file ('/opt/zapret/dump.xml');
$dirty = array();
$excl = array();
$excl[] = 'youtube.com';
$excl[] = 'google.ru';
$excl[] = 'google.com';
$excl[] = 'badsite.org';
foreach($xml as $node) {
if( strlen( (string)$node->domain )>0 ) {
$parsed = parse_url((string)$node->url);
if( $parsed!=false ) {
if( isset($parsed['path']) ) {
if( isset($parsed['scheme']) )
$scheme = $parsed['scheme'] . "://";
else
$scheme = "http://";
if( isset($parsed['port']) ) {
$port = ':' . $parsed['port'];
if( $scheme=="https://" ) $port = $port . " ssl";
}
else {
if( $scheme=="https://" ) $port = ":443 ssl";
else $port = ":80";
}
$port = $port . ";";
$domain = (string)$node->domain;
if( strcmp(strtolower(substr($domain, 0, 4)), 'www.') == 0 ) $domain = substr($domain, 4);
if( isset($parsed['query']) )
$que = $parsed['query'];
else
$que = '';
$que = str_replace('\E', '\E\\E\Q', $que);
$que = '\Q' . $que . '\E';
if ( strcmp($que, '\Q\E')==0 )
$que = '';
$path = $parsed['path'];
$path = str_replace('\E', '\E\\E\Q', $path);
if ( strcmp($path, '/')<>0 ) {
$path = '\Q' . $path . '\E';
if ( strcmp($path, '\Q\E')==0 )
$path = '';
}
$keys = preg_grep('/' . $domain . '/', $excl);
if( count($keys)<1 )
$dirty[] = array('domain'=>$domain, 'url'=>(string)$node->url, 'loc'=>$path, 'query'=>$que, 'port'=>$port, 'scheme'=>$scheme);
}
}
}
}
// Т.к. он забанен весь, а в списке фрагментарно
$dirty[] = array('domain'=>'badsite.org', 'url'=>'badsite.org', 'loc'=>'/', 'query'=>'', 'port'=>':80;', 'scheme'=>'http://');
$sort_func = function($obj_1, $obj_2) {
return strnatcasecmp($obj_1['domain'] . $obj_1['url'], $obj_2['domain'] . $obj_2['url']);
};
$domains = array_unique($dirty, SORT_REGULAR);
usort($domains, $sort_func);
$old_domain = "";
$old_loc = "";
$wasroot = false;
$alldomain = false;
$allloc = false;
foreach($domains as $node) {
$domain = $node['domain'];
$url = $node['url'];
$loc = $node['loc'];
$query = $node['query'];
$port = $node['port'];
$scheme = $node['scheme'];
// echo "n1. Root " . (string)$wasroot . "; alldomain " . (string)$alldomain . "; alloc " . (string) $allloc . "; loc '" . $loc . "'; query '" . $query . "'n";
if( strcmp($domain, $old_domain) ) {
loc_close( $old_loc, ($alldomain || $wasroot || $allloc));
dom_close( $old_domain, $wasroot );
dom_open( $domain, $port, $scheme );
$old_loc = '';
$alldomain = false;
$wasroot = false;
}
if( !$alldomain ){
if( strcmp($loc, $old_loc) ) {
loc_close( $old_loc, ($alldomain || $allloc) );
loc_open( $loc );
$allloc = false;
}
if( strlen($loc)<2 && strlen($query)>0 )
$wasroot = true;
if( strlen($loc)<2 && strlen($query)<1 )
{ $alldomain = true; $wasroot = true; }
if( strlen($query)<1 )
$allloc = true;
if( !$allloc )
args_check( $query );
}
// echo "n2. Root " . (string)$wasroot . "; alldomain " . (string)$alldomain . "; alloc " . (string) $allloc . "; loc '" . $loc . "'; query '" . $query . "'n";
$old_loc = $loc;
$old_domain = $domain;
}
loc_close( $loc, ($alldomain || $wasroot || $allloc) );
dom_close( $domain, $wasroot );
function loc_close( $_loc, $_alldomain ) {
if (strlen($_loc)>0 ) {
if( $_alldomain ) {
?>
return 301 http://eais.rkn.gov.ru/;
<?php
}
else { //if ( strcmp($_loc, '/')==0 ) {
?>
include /etc/nginx/proxy_params;
if ($args = '') {
proxy_pass $scheme://$host$uri;
}
if ($args != '') {
proxy_pass $scheme://$host$uri?$args;
}
<?php
}
echo " } # locationn";
}
}
function dom_close( $_dom, $_wasroot )
{
if( strlen($_dom)>0 ) {
if( !$_wasroot ) {
?>
location / {
include /etc/nginx/proxy_params;
if ($args = '') {
proxy_pass $scheme://$host$uri;
}
if ($args != '') {
proxy_pass $scheme://$host$uri?$args;
}
} #root location
<?php
}
echo "} #domainn";
}
}
function loc_open( $_loc )
{
?>
location ~* <?php echo $_loc . "* {n"; ?>
<?php
}
function dom_open( $_domain, $_port, $_scheme )
{
?>
server {
listen 1.2.3.4<?php echo $_port;
if( strcmp( $_scheme, 'https://' )==0 ) echo ' ssl';
?>
server_name <?php echo $_domain . " " . "*." . $_domain . ";n";
}
function args_check( $_query ) {
if ( strlen($_query)>0 ) {
echo "t if ($args ~* "";
if(strlen($_query)>0) echo $_query;
echo "*") {n";
echo "ttreturn 301 http://eais.rkn.gov.ru/;n";
echo "t } #argsn";
}
else echo "treturn 301 http://eais.rkn.gov.ru/;n";
}
?>
По итогам исполнения третьего скрипта мы получаем конфигурацию для nginx, которая проксирует те доменные имена, которые получила. В случае, если адрес заблокирован, то осуществляется безусловный редирект (301) на адрес eais.rkn.gov.ru — реестра запрещенных сайтов.
Блокировка может быть трех типов:
1. Весь домен. Для таких сайтов мы получаем следующую запись:
server {
listen 1.2.3.4:80;
server_name badsite.org *.badsite.org;
location ~* /* {
return 301 http://eais.rkn.gov.ru/;
} # location
} #domain
2. Определенные URL в домене. В этом случае мы получаем другую запись:
server {
listen 1.2.3.4:80;
server_name badsite.hk *.badsite.hk;
location ~* Q/h/E* {
return 301 http://eais.rkn.gov.ru/;
} # location
location ~* Q/h/res/214.htmlE* {
return 301 http://eais.rkn.gov.ru/;
} # location
location / {
include /etc/nginx/proxy_params;
if ($args = '') {
proxy_pass $scheme://$host$uri;
}
if ($args != '') {
proxy_pass $scheme://$host$uri?$args;
}
} #root location
} #domain
3. Определенные аргументы у определенного URL (например, конкретный пост в PHPBB):
server {
listen 1.2.3.4:80;
server_name badsite.com *.badsite.com;
location ~* Q/forum/viewforum.phpE* {
if ($args ~* "Qf=6E*") {
return 301 http://eais.rkn.gov.ru/;
} #args
if ($args ~* "Qf=6&start=25E*") {
return 301 http://eais.rkn.gov.ru/;
} #args
include /etc/nginx/proxy_params;
if ($args = '') {
proxy_pass $scheme://$host$uri;
}
if ($args != '') {
proxy_pass $scheme://$host$uri?$args;
}
} # location
location / {
include /etc/nginx/proxy_params;
if ($args = '') {
proxy_pass $scheme://$host$uri;
}
if ($args != '') {
proxy_pass $scheme://$host$uri?$args;
}
} #root location
} #domain<
И, конечно, в default есть проброс всех запросов к соответствующим адресам (на всякий случай):
server {
listen 1.2.3.4:80 default_server;
location / {
include /etc/nginx/proxy_params;
if ($args = '') {
proxy_pass $scheme://$host$uri;
}
if ($args != '') {
proxy_pass $scheme://$host$uri?$args;
}
}
}
server {
listen 1.2.3.4:443 ssl default_server;
location / {
include /etc/nginx/proxy_params;
if ($args = '') {
proxy_pass $scheme://$host$uri;
}
if ($args != '') {
proxy_pass $scheme://$host$uri?$args;
}
}
}
После генерации конфигурации необходимо не забыть сказать
# service nginx reload
что сообщит nginx о необходимости перезагрузить конфигурацию, мягко погасив старые пулы.
Система с nginx проходила проверку на прочность неделю назад, когда в данный список был внесен один ролик с youtube.com. Кроме повышенного расхода памяти побочных эффектов замечено не было. С расходом памяти удалось побороться отключив keep-alive соединения с клиентами. А вот с удобством для пользователей вышло, конечно, не очень: просмотр и загрузка роликов на youtube.com в целом работала, но многие ролики были внедрены в другие страницы с помощью https, а с подмененным сертификатом браузеры их отображать не хотели. Волевым решением руководства провайдера домены google.com, google.ru, youtube.com были вынесены в список исключений, а еще один из сайтов был внесен в список «исключений наоборот»: по нему есть давнее решение блокировать целиком, однако в данном реестре он выгружается только с двумя запрещенными URL.
В целом данное решение показало себя вполне работоспособным для маленького провайдера, который хочет продолжать работать в непростых условиях нашего Российского законодательства.
Автор: kolu4iy