Прозрачный обход блокировок в домашней сети

от автора

Последние новости в очередной раз заострили проблему блокировок интернет-ресурсов. С одной стороны о способах их обхода написано немало, и пережевывать эту тему в очередной раз казалось бы незачем. С другой, регулярно предпринимать какие-то дополнительные действия для посещения нужного ресурса — это не совсем то, что должно удовлетворить айтишника (и не всегда то, с чем может справится человек к айти неблизкий).

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

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

Собственно, поднять на маршрутизаторе и гонять весь траффик через 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>&params=<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 (либо отключить опции в конфиге скрипта).

Скрипт обновления списков блокировки

/usr/bin/rublupdate.lua

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

/etc/dnsmasq.conf

server=/onion/127.0.0.1#9053 ipset=/onion/onion  conf-file=/etc/runblock/runblock.dnsmasq 

Добавить в секцию dnsmasq /etc/config/dhcp

    list server '8.8.8.8'     list server '8.8.4.4'     list rebind_domain 'onion' 

Настройки netfilter

Добавить в /etc/config/firewall

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' 

Добавить в /etc/firewall.user

cat /etc/runblock/runblock.ipset | ipset restore 

Настройки Tor

/etc/torrc

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 (пару раз в сутки — вполне достаточно) и забыть о роскомнадзоре. Ну до тех пор, пока не начнут блокировать тор, или сайты, публикующие реестр).

ссылка на оригинал статьи http://habrahabr.ru/post/270657/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *