[Из песочницы] Прозрачный обход блокировок в домашней сети
Последние новости в очередной раз заострили проблему блокировок интернет-ресурсов. С одной стороны о способах их обходанаписано немало, и пережевывать эту тему в очередной раз казалось бы незачем. С другой, регулярно предпринимать какие-то дополнительные действия для посещения нужного ресурса — это не совсем то, что должно удовлетворить айтишника (и не всегда то, с чем может справится человек к айти неблизкий).
Нужно простое и прозрачное для пользователей решение, которое, будучи единожды настроенным, позволит просто пользоваться интернетом, не задумываясь, что же сегодня заблокировали по заявкам очередных копирастов-плагиаторов.
Сама собой напрашивается мысль о том, чтобы обходить блокировку уже на домашнем маршрутизаторе.
Собственно, поднять на маршрутизаторе и гонять весь траффик через VPN несложно, а у некоторых VPN-провайдеров есть даже пошаговые инструкции по настройке OpenWrt на работу с ними.
Но скорости VPN сервисов все же отстают от скоростей доступа в интернет, да и VPN-сервис либо стоит денег, либо имеет массу ограничений, либо необходимость регулярного получения новых логинов. С точки зрения оптимизации затрат, как финансовых, так и временных, предпочтительней выглядит Tor, но его скорость еще хуже, а гонять через Tor торренты и вовсе идея не лучшая.
Выход — перенаправлять в VPN/Tor только траффик блокируемых ресурсов, пропуская остальной обычным путем.
Внимание: данная схема не обеспечивает анонимности просмотра заблокированных сайтов: любая внешняя ссылка раскрывает ваш настоящий IP.
Конкретная реализация на OpenWrt приведена в конце статьи. Если не интересуют подробности и альтернативные варианты решения, то можно листать сразу до нее.
Туннелирование и перенаправление траффика в туннель
Настройка VPN или Tor'а сложностей представлять не должна. Tor должен быть настроен, как прозрачный proxy (либо настроить связку из tor и tun2socks). Т.к. конечной целью явлется обход блокировок ркн, то в конфиге Tor'а целесообразно запретить использование выходных узлов на территории РФ (<ExcludeExitNodes {RU}
).
В Tor’а траффик перенаправляется правилом с REDIRECT
’ом на порт прозрачного прокси в цепочке PREROUTING
таблицы nat
netfilter’а.
Для перенаправления в VPN (или Tor + tun2socks) траффик маркируется в таблице mangle
, метка затем используется для выбора таблицы маршрутизации, перенаправляющей траффик на соответствующий интерфейс.
В обоих случаях для классификации траффика используется ipset
с хостами, подлежащими (раз)блокировке.
Формирование ipset
c (раз)блокируемыми хостами
К сожалению, вариант «загнать все IP из реестра» в ipset не работает как хотелось бы: во-первых в списках присутствуют не все IP адреса блокируемых хостов, во-вторых в попытке уйти от блокировки IP адрес у ресурса может измениться (и провайдер об этом уже знает, а мы – еще нет), ну и в третьих – false positives для находящихся на том же shared hosting’е сайтов.
Городить огород с dpi того или иного вида не очень хочется: как-никак работать это должно на довольно слабом железе. Выход достаточно прост и в какой-то степени элегантен: dnsmasq (DNS сервер, который на маршрутизаторе скорее всего уже установлен) умеет при разрешении имен добавлять ip-адреса в соответствующий ipset (одноименная опция в конфиге). Как раз то, что нужно: вносим в конфиг все домены, которые необходимо разблокировать, и дальше по необходимости dnsmasq сам добавляет в ipset именно тот ip адрес, по которому будет идти обращение к заблокированному ресурсу.
У меня были сомнения, что dnsmasq запустится и будет нормально работать с конфигом в полдесятка тысяч строк (примерно столько записей в реестре после усушки и утряски), однако они к счастью оказались безосновательны.
Ложка дегтя в том, что при обновлении списка dnsmasq придется перезапускать, т.к. по SIGHUP он конфиг не перегружает.
Составление списка доменов
Должно происходить автоматически, насколько это возможно.
Первый вариант (который и реализован в примере): формировать список на основе единого реестра блокировок и обновлять его по cron'у.
Роскомнадзор широкой общественности реестр блокировок не предоставляет, однако мир не без добрых людей и есть минимум два ресурса, где с ним можно ознакомиться. И, что отлично, API у них тоже имеется. При разборе списка нужно учесть, что в списке доменных имен помимо собственно доменных имен присутствуют и IP адреса. Их нужно обрабатывать отдельно (или вообще на них забить: их примерно 0,1% от списка и врядли они ведут на интересующие вас ресурсы). Кириллические домены далеко не всегда представлены в punycode. Немалую часть списка занимают поддомены на одном домене второго уровня, указаны домены с www/без www и просто дублирующиеся записи. Все перечисленное в большей степени относится к списку от rublacklist.net (он в добавок еще и странно, местами некорректно, экранирован). Именно для него пришлось городить монструозный lua-script (приводится ниже), нормализующий и сжимающий список почти в два раза. C antizapret.info ситуация сильно лучше и можно было бы обойтись однострочником на awk.
Можно пойти другим путем: многие провайдеры при обращению к заблокированному ресурсу перенаправляют на заглушку об ограничении доступа. Например http://block.mts.ru/?host=<host>&url=<url>¶ms=<params>
. Подменив с помощью того же dnsmasq (address=/block.mts.ru/192.168.1.1
) A-запись block.mts.ru на адрес веб-сервера маршрутизатора (и разместив на нем несложный скрипт) можно локально формировать список запрошенных пользователями сети заблокированных ресурсов, добавлять их в конфиг dnsmasq, повторно делать nslookup (чтобы ip адрес добавился в ipset) и еще раз редиректить пользователя на первоначальный URL. Но необходимость каждый раз при этом перезапускать dnsmasq несколько расхолаживает. Да и работать будет только для http.
Теперь еще об одной ложке дегтя: некоторое провайдеры замечены за тем, что помимо включенных в список ркн сайтов самодеятельно блокируют и официально в списках не значащиеся. При этом блокируют тихой сапой и заглушки не выводят. Так что совсем без ручного привода не обойтись.
Дополнительные замечания
DNS серверы провайдера использовать в качестве апстрим серверов естественно не стоит. Ибо блокировка может произойти еще на стадии разрешения имени ресурса. Отдаст сервер провайдера на искомый адрес, что это CNAME block.mts.ru и все. Наиболее простое решение server=8.8.8.8, server=8.8.4.4
. Модификации провайдерами DNS-ответов сторонних серверов лично я пока не наблюдал. В случае, если начнут — можно отправлять запросы доменов из запретного списка на другой апстрим (через VPN/Tor), однако без надобности я бы конфиг не раздувал.
При использовании Tor'a можно бонусом получить возможность серфинга по .onion сайтам: Tor при разрешении имени через встроенный dns-сервер отобразит его на виртуальный адрес из заранее заданного диапазона. Дальше нужно только перенаправить обращение к этому адресу на прокси Tor'а и voila. Но еще раз напомню, что анонимности подключение с избирательным туннелированием трафика не обеспечивает.
Реализация на OpenWrt (15.05)
Сам маршрутизатор должен быть не самый плохой, особенно при использовании Tor’а. MIPS 400MHz@32MB RAM это тот минимум, который стоит рассматривать.
При наличии USB-порта недостаток встроенного флеша можно компенсировать USB-флешкой (вообще мне представляется достаточно здравой идея не использовать встроенный флеш для регулярно перезаписываемых данных).
Штатно в прошивках OpenWrt содержится урезанный dnsmasq, не умеющий ipset. Необходимо заменить его на dnsmasq-full.
Из пакетов, по умолчанию не присутствующих, так же потребуются ipset, tor и tor-geoip.
Так же необходим либо пакет luasocket, либо (в режиме строгой экономии флеша) отдельно ltn12.lua в папке /usr/lib/lua. Для преобразования кириллических доменов из utf8 в punycode нужны idn.lua в /usr/lib/lua и пакет luabitop (либо отключить опции в конфиге скрипта).
Скрипт обновления списков блокировки
local config = {
blSource = "antizapret", -- antizapret или rublacklist
groupBySld = 32, -- количество поддоменов после которого в список вносится весь домен второго уровня целиком
neverGroupMasks = { "^%a%a%a?.%a%a$" }, -- не праспространять на org.ru, net.ua и аналогичные
neverGroupDomains = { ["livejournal.com"] = true, ["facebook.com"] = true , ["vk.com"] = true },
stripWww = true,
convertIdn = true,
torifyNsLookups = false, -- отправлять DNS запросы заблокированных доменов через TOR
blMinimumEntries = 1000, -- костыль если список получился короче, значит что-то пошло не так и конфиги не обновляем
dnsmasqConfigPath = "/etc/runblock/runblock.dnsmasq",
ipsetConfigPath = "/etc/runblock/runblock.ipset",
ipsetDns = "rublack-dns",
ipsetIp = "rublack-ip",
torDnsAddr = "127.0.0.1#9053"
}
local function prequire(package)
local result, err = pcall(function() require(package) end)
if not result then
return nil, err
end
return require(package) -- return the package value
end
local idn = prequire("idn")
if (not idn) and (config.convertIdn) then
error("you need either put idn.lua (github.com/haste/lua-idn) in script dir or set 'convertIdn' to false")
end
local http = prequire("socket.http")
if not http then
local ltn12 = require("ltn12")
end
if not ltn12 then
error("you need either install luasocket package (prefered) or put ltn12.lua in script dir")
end
local function hex2unicode(code)
local n = tonumber(code, 16)
if (n < 128) then
return string.char(n)
elseif (n < 2048) then
return string.char(192 + ((n - (n % 64)) / 64), 128 + (n % 64))
else
return string.char(224 + ((n - (n % 4096)) / 4096), 128 + (((n % 4096) - (n % 64)) / 64), 128 + (n % 64))
end
end
local function rublacklistExtractDomains()
local currentRecord = ""
local buffer = ""
local bufferPos = 1
local streamEnded = false
return function(chunk)
local retVal = ""
if chunk == nil then
streamEnded = true
else
buffer = buffer .. chunk
end
while true do
local escapeStart, escapeEnd, escapedChar = buffer:find("\\(.)", bufferPos)
if escapedChar then
currentRecord = currentRecord .. buffer:sub(bufferPos, escapeStart - 1)
bufferPos = escapeEnd + 1
if escapedChar == "n" then
retVal = currentRecord
break
elseif escapedChar == "u" then
currentRecord = currentRecord .. "\\u"
else
currentRecord = currentRecord .. escapedChar
end
else
currentRecord = currentRecord .. buffer:sub(bufferPos, #buffer)
buffer = ""
bufferPos = 1
if streamEnded then
if currentRecord == "" then
retVal = nil
else
retVal = currentRecord
end
end
break
end
end
if retVal and (retVal ~= "") then
currentRecord = ""
retVal = retVal:match("^[^;]*;([^;]+);[^;]*;[^;]*;[^;]*;[^;]*.*$")
if retVal then
retVal = retVal:gsub("\\u(%x%x%x%x)", hex2unicode)
else
retVal = ""
end
end
return (retVal)
end
end
local function antizapretExtractDomains()
local currentRecord = ""
local buffer = ""
local bufferPos = 1
local streamEnded = false
return function(chunk)
local haveOutput = 0
local retVal = ""
if chunk == nil then
streamEnded = true
else
buffer = buffer .. chunk
end
local newlinePosition = buffer:find("\n", bufferPos)
if newlinePosition then
currentRecord = currentRecord .. buffer:sub(bufferPos, newlinePosition - 1)
bufferPos = newlinePosition + 1
retVal = currentRecord
else
currentRecord = currentRecord .. buffer:sub(bufferPos, #buffer)
buffer = ""
bufferPos = 1
if streamEnded then
if currentRecord == "" then
retVal = nil
else
retVal = currentRecord
end
end
end
if retVal and (retVal ~= "") then
currentRecord = ""
end
return (retVal)
end
end
local function normalizeFqdn()
return function(chunk)
if chunk and (chunk ~= "") then
if config["stripWww"] then chunk = chunk:gsub("^www%.", "") end
if idn and config["convertIdn"] then chunk = idn.encode(chunk) end
if #chunk > 255 then chunk = "" end
chunk = chunk:lower()
end
return (chunk)
end
end
local function cunstructTables(bltables)
bltables = bltables or { fqdn = {}, sdcount = {}, ips = {} }
local f = function(blEntry, err)
if blEntry and (blEntry ~= "") then
if blEntry:match("^%d+%.%d+%.%d+%.%d+$") then
-- ip адреса - в отдельную таблицу для iptables
if not bltables.ips[blEntry] then
bltables.ips[blEntry] = true
end
else
-- как можем проверяем, FQDN ли это. заодно выделяем домен 2 уровня (если в bl станут попадать TLD - дело плохо :))
local subDomain, secondLevelDomain = blEntry:match("^([a-z0-9%-%.]-)([a-z0-9%-]+%.[a-z0-9%-]+)$")
if secondLevelDomain then
bltables.fqdn[blEntry] = secondLevelDomain
if 1 > 0 then
bltables.sdcount[secondLevelDomain] = (bltables.sdcount[secondLevelDomain] or 0) + 1
end
end
end
end
return 1
end
return f, bltables
end
local function compactDomainList(fqdnList, subdomainsCount)
local domainTable = {}
local numEntries = 0
if config.groupBySld and (config.groupBySld > 0) then
for sld in pairs(subdomainsCount) do
if config.neverGroupDomains[sld] then
subdomainsCount[sld] = 0
break
end
for _, pattern in ipairs(config.neverGroupMasks) do
if sld:find(pattern) then
subdomainsCount[sld] = 0
break
end
end
end
end
for fqdn, sld in pairs(fqdnList) do
if (not fqdnList[sld]) or (fqdn == sld) then
local keyValue;
if config.groupBySld and (config.groupBySld > 0) and (subdomainsCount[sld] > config.groupBySld) then
keyValue = sld
else
keyValue = fqdn
end
if not domainTable[keyValue] then
domainTable[keyValue] = true
numEntries = numEntries + 1
end
end
end
return domainTable, numEntries
end
local function generateDnsmasqConfig(configPath, domainList)
local configFile = assert(io.open(configPath, "w"), "could not open dnsmasq config")
for fqdn in pairs(domainList) do
if config.torifyNsLookups then
configFile:write(string.format("server=/%s/%s\n", fqdn, config.torDnsAddr))
end
configFile:write(string.format("ipset=/%s/%s\n", fqdn, config.ipsetDns))
end
configFile:close()
end
local function generateIpsetConfig(configPath, ipList)
local configFile = assert(io.open(configPath, "w"), "could not open ipset config")
configFile:write(string.format("flush %s-tmp\n", config.ipsetIp))
for ipaddr in pairs(ipList) do
configFile:write(string.format("add %s %s\n", config.ipsetIp, ipaddr))
end
configFile:write(string.format("swap %s %s-tmp\n", config.ipsetIp, config.ipsetIp))
configFile:close()
end
local retVal, retCode, url
local output, bltables = cunstructTables()
if config.blSource == "rublacklist" then
output = ltn12.sink.chain(ltn12.filter.chain(rublacklistExtractDomains(), normalizeFqdn()), output)
url = "http://reestr.rublacklist.net/api/current"
elseif config.blSource == "antizapret" then
output = ltn12.sink.chain(ltn12.filter.chain(antizapretExtractDomains(), normalizeFqdn()), output)
url = "http://api.antizapret.info/group.php?data=domain"
else
error("blacklist source should be either 'rublacklist' or 'antizapret'")
end
if http then
retVal, retCode = http.request { url = url, sink = output }
else
retVal, retCode = ltn12.pump.all(ltn12.source.file(io.popen("wget -qO- " .. url)), output)
end
if (retVal == 1) and ((retCode == 200) or (http == nil)) then
local domainTable, recordsNum = compactDomainList(bltables.fqdn, bltables.sdcount)
if recordsNum > config.blMinimumEntries then
generateDnsmasqConfig(config.dnsmasqConfigPath, domainTable)
generateIpsetConfig(config.ipsetConfigPath, bltables.ips)
print(string.format("blacklists updated. %d entries.", recordsNum))
os.exit(0)
end
end
os.exit(1)
Настройки dnsmasq
server=/onion/127.0.0.1#9053
ipset=/onion/onion
conf-file=/etc/runblock/runblock.dnsmasq
list server '8.8.8.8'
list server '8.8.4.4'
list rebind_domain 'onion'
Настройки netfilter
config ipset
option name 'rublack-dns'
option storage 'hash'
option match 'dest_ip'
option timeout '86400'
config ipset
option name 'rublack-ip'
option storage 'hash'
option match 'dest_ip'
config ipset
option name 'rublack-ip-tmp'
option storage 'hash'
option match 'dest_ip'
config ipset
option name 'onion'
option storage 'hash'
option match 'dest_ip'
option timeout '86400'
config redirect
option name 'torify-blocked-dns'
option src 'lan'
option proto 'tcp'
option ipset 'rublack-dns'
option dest_port '9040'
option dest 'lan'
config redirect
option name 'torify-blocked-ip'
option src 'lan'
option proto 'tcp'
option ipset 'rublack-ip'
option dest_port '9040'
option dest 'lan'
config redirect
option name 'torify-onion'
option src 'lan'
option proto 'tcp'
option ipset 'onion'
option dest_port '9040'
option dest 'lan'
cat /etc/runblock/runblock.ipset | ipset restore
Настройки Tor
User tor
PidFile /var/run/tor.pid
DataDirectory /var/lib/tor
ExcludeExitNodes {RU}
VirtualAddrNetwork 10.254.0.0/16 # виртуальные адреса для .onion ресурсов
AutomapHostsOnResolve 1
TransPort 9040
TransListenAddress 127.0.0.1
TransListenAddress 192.168.1.1 #адрес LAN интерфейса
DNSPort 9053
DNSListenAddress 127.0.0.1
#AvoidDiskWrites 1 # в OpenWrt /var и так в RAM (tmpfs) не уверен, что в опции есть смысл
Осталось создать каталог /etc/runblock
, разово запустить вручную скрипт lua /usr/bin/rublupdate.lua
, убедиться, что он отработал без ошибок, добавить его в cron (пару раз в сутки — вполне достаточно) и забыть о роскомнадзоре. Ну до тех пор, пока не начнут блокировать тор, или сайты, публикующие реестр).