Обход блокировки РКН с помощью магии Service Worker'ов +29


AliExpress RU&CIS



Приветствую, Хабр!

Я не претендую на срывание покров или какой-то революционный способ, но данный метод позволит как минимум сохранить ту часть трафика, так преданного вашему проекту/сайту/блогу, и немного вернуть справедливость со всеми этими перипетиями с массовыми блокировками.

TL;DR


Суть способа в обыгрывании возможности Service Worker'ов проверять контент на подконтрольных ему страницам. Если воркер не находит определённого текста на странице — происходит редирект. Таким образом вместо заглушки провайдера о том, что сайт заблокирован пользователь переходит на незаблокированный домен.

Этап 1


Итак, для приготовления нам понадобится всего ничего:

  • сайт, который (пока ещё) не заблокирован;
  • источник, который при запросе на него будет выдавать URL на новый, незаблокированный ресурс (о них немного позже);
  • JS файл — сервис-воркер, который мы будем использовать по прямому назначению, а именно, если руководствоваться статьёй:
    Одной из важнейших проблем, от которой страдали пользователи веб-приложений, была работа в условиях потери связи

Начнём, пожалуй, с основы нашего воркера — переменных и констант:

// DEBUG_MODE - при true будет выводить в console log некоторые результаты выполнения наших функций
const DEBUG_MODE = false;
const DNS_RESOLVER_URL = "https://dns.google.com/resolve?type=TXT&name=";

var settings = {
    enabled: 1,
    block_id: "<!-- RKN-BLOCK-URANUS-PLS -->", // Часть контента, при отсутствии которого наш воркер будет считать, что страница заблокирована
    redirect_url: "//google.com", // Fallback URL, если не нашли настроек для текущего домена
    dns_domains: ["subdomain.somesite.com", "subdomain.somesite.ru"] // Наши домены, в DNS ТХТ-записях у которых хранятся наши настройки. Если что-то случится с одним - воркер проверит на другом и далее по списку
};

var redirect_params = {
    utm_term: self.location.hostname+'_swredir' // Исключительно для удобства добавляем ко всем редиректам utm_term, чтобы было понятно откуда и сколько мы спасли людей
};

Установим event'ы fetch и install. Очевидно, это та «база» которая будет выполнять необходимые действия при установке воркера и каждом отдельном запросе к подконтрольным сервис воркеру ресурсам:

self.addEventListener("install", function () {
    self.skipWaiting();
    checkSettings();
    log("Install event");
});

self.addEventListener("fetch", function (event) {
    if (event.request.redirect === "manual" && navigator.onLine === true) {
        event.respondWith(async function() {
            await checkSettings();
            return fetch(event.request)
                .then(function (response) {
                    return process(response, event.request.url);
                })
                .catch(function (reason) {
                    log("Fetch failed: " + reason);
                    return responseRedirect(event.request.url);
                });
        }());
    }
});

Как вы заметили, в этой части мы используем функцию checkSettings(), с помощью которой мы и получаем набор настроек для домена, которые мы будем хранить в DNS TXT-записи того же или любого другого домена.

Конкретно в моём варианте используется текстовая версия DNS-резолвера от Google, но, возможно, вы сможете придумать что-то лучше. Пишите в комментарии.

function checkSettings(i = 0) {
    return fetch(DNS_RESOLVER_URL + settings.dns_domains[i], {cache: 'no-cache'})
        .then(function (response) {
            return response.clone().json();
        })
        .then(function (data) {
            return JSON.parse(data['Answer'][0]['data']);
        })
        .then(function (data) {
            settings.enabled = data[1];
            settings.block_id = (data[2]) ? data[2] : settings.block_id;
            settings.redirect_url = (data[3]) ? data[3] : settings.redirect_url;
            settings.last_update = Date.now();
            log("Settings updated: " + JSON.stringify(settings));
            return true;
        })
        .catch(function (reason) {
            if (settings.dns_domains.length - 1 > i) {
                log("Check settings on other domains DNS TXT: " + reason);
                return checkSettings(++i);
            } else {
                settings.enabled = 0;
                log("Settings error: " + reason);
                return false;
            }
        });
}

Как видно из функции checkSettings — мы обращаемся непосредственно к API DNS-резолвера гугла, дабы получить наш набор настроек. Что же наш воркер ожидает увидеть?
Набор параметров в виде JSON:
{"1": 1, "2": "<!-- RKN-BLOCK-URANUS-PLS -->", "3": "https://notblocked.ru"}
, где 1 — это параметр «enabled», которым мы указываем редиректить или нет в случае недоступности искомого контента на странице, 2 — собственно, сам искомый текст, 3 — домен, на который будем перенаправлять пользователя в случае отсутствия текста.

Осталось дело за малым — подключить наш воркер на всех страницах нашего сайта:

<script>navigator.serviceWorker.register('/rp-sw.js');</script>

Эпилог


Итак, наш сайт пока не заблокирован, DNS-записи готовы, SW подключен.

Мы в полном обмундировании готовы встречать блокировку.

И, конечно же, выкладываю полный вариант моего воркера:

rp-sw.js
// DEBUG_MODE - при true будет выводить в console log некоторые результаты выполнения наших функций
const DEBUG_MODE = false;
const DNS_RESOLVER_URL = "https://dns.google.com/resolve?type=TXT&name=";

var settings = {
    enabled: 1,
    block_id: "<!-- RKN-BLOCK-URANUS-PLS -->", // Часть контента, при отсутствии которого наш воркер будет считать, что страница заблокирована
    redirect_url: "//google.com", // Fallback URL, если не нашли настроек для текущего домена, то куда будем редиректить если enabled: 1
    dns_domains: ["subdomain.somesite.com", "subdomain.somesite.ru"] // Наши домены, в DNS ТХТ-записях у которых хранятся наши настройки
};

var redirect_params = {
    utm_term: self.location.hostname+'_swredir'
};

function getUrlParams(url, prop) {
    var params = {};
    url = url || '';
    var searchIndex = url.indexOf('?');
    if (-1 === searchIndex || url.length === searchIndex + 1) {
        return {};
    }
    var search = decodeURIComponent( url.slice( searchIndex + 1 ) );
    var definitions = search.split( '&' );

    definitions.forEach( function( val, key ) {
        var parts = val.split( '=', 2 );
        params[ parts[ 0 ] ] = parts[ 1 ];
    } );

    return ( prop && params.hasOwnProperty(prop) ) ? params[ prop ] : params;
}

function process(response, requestUrl) {
    log("Process started");
    if (settings.enabled === 1) {
        return response.clone().text()
            .then(function(body) {
                if (checkBody(body)) {
                    log("Check body success");
                    return true;
                }
            })
            .then(function (result) {
                if (result) {
                    return response;
                } else {
                    log("Check failed. Send redirect to: " + getRedirectUrl(settings.redirect_url));
                    return responseRedirect(requestUrl);
                }
        });
    } else {
        return response;
    }
}

function checkBody(body) {
    return (body.indexOf(settings.block_id) >= 0);
}

function checkSettings(i = 0) {
    return fetch(DNS_RESOLVER_URL + settings.dns_domains[i], {cache: 'no-cache'})
        .then(function (response) {
            return response.clone().json();
        })
        .then(function (data) {
            return JSON.parse(data['Answer'][0]['data']);
        })
        .then(function (data) {
            settings.enabled = data[1];
            settings.block_id = (data[2]) ? data[2] : settings.block_id;
            settings.redirect_url = (data[3]) ? data[3] : settings.redirect_url;
            settings.last_update = Date.now();
            log("Settings updated: " + JSON.stringify(settings));
            return true;
        })
        .catch(function (reason) {
            if (settings.dns_domains.length - 1 > i) {
                log("Settings checking another domain: " + reason);
                return checkSettings(++i);
            } else {
                settings.enabled = 0;
                log("Settings error: " + reason);
                return false;
            }
        });
}

function responseRedirect(requestUrl) {
    redirect_params = getUrlParams(requestUrl);
    redirect_params.utm_term = self.location.hostname+'_swredir';

    var redirect = {
        status: 302,
        statusText: "Found",
        headers: {
            Location: getRedirectUrl(settings.redirect_url)
        }
    };

    return new Response('', redirect);
}

function getRedirectUrl(url) {
    url += (url.indexOf('?') === -1 ? '?' : '&') + queryParams(redirect_params);
    return url;
}

function queryParams(params) {
    return Object.keys(params).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])).join('&');
}

function log(text) {
    if (DEBUG_MODE) {
        console.log(text);
    }
}

self.addEventListener("install", function () {
    self.skipWaiting();
    checkSettings();
    log("Install event");
});

self.addEventListener("fetch", function (event) {
    if (event.request.redirect === "manual" && navigator.onLine === true) {
        event.respondWith(async function() {
            await checkSettings();
            return fetch(event.request)
                .then(function (response) {
                    return process(response, event.request.url);
                })
                .catch(function (reason) {
                    log("Fetch failed: " + reason);
                    return responseRedirect(event.request.url);
                });
        }());
    }
});




Комментарии (29):