Android клиент для rutracker: обходим блокировку при помощи Google Compression proxy

в 18:15, , рубрики: android, Google, java, Peer-to-Peer, rutracker.org, Веб-разработка, Разработка под android

Полагаю, что все пользователя хабра так или иначе нашли способ попадать на рутрекер, но порой бывает лень включать свой тор, прокси, впн или что либо ещё. Мне вот стало лень, и поэтому я решил написать свой маленький клиент. Для обхода блокировок я решил использовать google compression proxy. Интересная, хорошая и полезная штука — странно, что по её поводу на хабре не было статей. Забегая вперёд, сразу скажу, что всё получилось, и работающую версию можно попробовать на своём девайсе. Однако в процессе возникло много всяких интересных нюансов, которые любопытны несколько больше, чем само приложение. Итак, начнём!

Google Compression proxy

Чтобы не повторять гугловые мануалы (все ссылки вы можете найти в конце статьи), просто скажу, что этот прокси сервер позволяет вашему Google Chrome значительно уменьшать количество воспринимаемого трафика за счёт его сжатия серверами Google. Работать прокси умеет по HTTP и по HTTPS. В первом случае используется адрес compress.googlezip.net, во втором — proxy.googlezip.net. Интересно, что для прокси требуется свой заголовок. В официальной документации его нет, однако можно найти исходники от гугла и немного в них покопаться. Выглядят они вот так (ссылка на самый интересный файл, размещено уже у меня на гихабе, поскольку в официальном репозитории на google code уже ничего посмотреть нельзя).
Оттуда получаем такое добро:

var authHeader = function() {
	var authValue = 'ac4500dd3b7579186c1b0620614fdb1f7d61f944';
	var timestamp = Date.now().toString().substring(0, 10);
	var chromeVersion = navigator.appVersion.match(/Chrome/(d+).(d+).(d+).(d+)/);
	return {
		name: 'Chrome-Proxy',
		value: 'ps=' + timestamp + '-' + Math.floor(Math.random() * 1000000000) + '-' + Math.floor(Math.random() * 1000000000) + '-' + Math.floor(Math.random() * 1000000000) + ', sid=' + MD5(timestamp + authValue + timestamp) + ', b=' + chromeVersion[3] + ', p=' + chromeVersion[4] + ', c=win'
	};
};

Всё довольно очевидно, но

можно остановиться подробнее.

Для каждого запроса должен присутствовать заголовок Chrome-Proxy.
В нём должна быть следующая строка:

 ps=<timestamp>-<num1>-<num2>-<num3>, sid=<md5 string>, b=<build>, p=<patch>, c=<platform>

где:

timestamp: время в linux timestamp
num1, num2, num3: некие случайные числа, которые можно поставить в 0
md5 string: md5 хэш строки авторизации
auth string:

"<timestamp>" + "<auth key>" + "<timestamp>"

auth key: ac4500dd3b7579186c1b0620614fdb1f7d61f944 — просто некий ключ… Один на всех, и все на одного.
build: номер билда хрома — например, 2214
patch: номер патча хрома — например, 115
platform: платформа — например, «win»
В качестве полного примера можно привести такой заголовок:

Chrome-Proxy: ps=1439961190-0-0-0, sid=9fb96126616582c4be88ab7fe26ef593, b=2214, p=115, c=win

Как ни странно и не смешно, можно использовать эту самую строку при любом количестве запросов без всяких изменений… Например, на этом основано расширение для Firefox, которое занимается пересжатием трафика. Видимо, просто делалась защита от ленивого дурака.
Однако, честным вариантом будет переписать это на Java так:

    public static String[] authHeader() {
        String[] result = new String[2];
        result[0] = "Chrome-Proxy";
        String authValue = "ac4500dd3b7579186c1b0620614fdb1f7d61f944";
        String timestamp = Long.toString(System.currentTimeMillis()).substring(0, 10);
        String[] chromeVersion = {"49", "0", "2623", "87"};
        String sid = (timestamp + authValue + timestamp);
        sid = Utils.md5(sid);
        result[1] = "ps=" + timestamp + "-" + Integer.toString((int) (Math.random() * 1000000000)) + "-" + Integer.toString((int) (Math.random() * 1000000000)) + "-" + Integer.toString((int) (Math.random() * 1000000000)) + ", sid=" + sid + ", b=" + chromeVersion[2] + ", p=" + chromeVersion[3] + ", c=win";
        return result;
    }

Далее надо выбрать, какой именно вариант для проксирования мы выбираем. Мой провайдер суров, и блокирует запросы, если они идут по HTTP через гугловую проксю, так что пришлось идти правильным путём через SSL.

WebView через SSL

Чтобы не идти долгим и печальным путём написания клиента с нуля, я решил «просто» показывать всё как есть через стандартный WebView, благо ранее уже писал простое-клиент, которое делает примерно то же самое, и шустро работает даже на тяжёлом веб сайте. Кажется — работы на полчаса. Как же я ошибался… Если посмотреть решения по проксированию WebView в интернете, то становится очень грустно — все делают примерно так:

public static void setKitKatWebViewProxy(Context appContext, String host, int port, String exclusionList) {
    Properties properties = System.getProperties();
    properties.setProperty("http.proxyHost", host);
    properties.setProperty("http.proxyPort", port + "");
    properties.setProperty("https.proxyHost", host);
    properties.setProperty("https.proxyPort", port + "");
    properties.setProperty("http.nonProxyHosts", exclusionList);
    properties.setProperty("https.nonProxyHosts", exclusionList);
/// ... such much shit
}

Оставшуюся часть намеренно опустил — там идёт ещё около сотни строк с условиями по версии андроида и жутким шаманством. При этом у многих это всё равно не работает, плюс есть проблемы с переключением режима проксирования — и её «решают» путём установки Thread.Sleep(1000) между операциями. Хотя я не являюсь Java разработчиком, а просто иногда балуюсь, мне поплохело. Здравый смысл подсказал мне, что нужно перехватывать запросы из Webview (для этого у WebViewClient есть чудесная функция shouldInterceptRequest), и далее самому заниматься проксированием. Это у меня даже вполне получилось:

  Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("compress.googlezip.net", 80));
  HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(proxy);

Всё отлично, всё работает! Только одна проблема. Как заметили внимательные читатели, в параметрах функции указан 80ый порт. По довольно смешной причине. А именно — потому что HttpURLConnection не умеет работать с HTTPS проксями. Совсем. Никак. У меня ушло куча времени, чтобы понять, что всё настолько плохо, и что нельзя сделать HTTPS прокси ни через HttpURLConnection, ни через популярный okHttp. Я немного призадумался, затем решительным жестом отмёл все доводы Google о том, что библиотеки Apache не подходят для Android, стряхнул пыль с проверенных jar'ов и решительно подключил их к проекту. И всё получилось! Пятый и шестой андроид на ура восприняли такой ужасный проступок. Если кто-то знает, как можно было решить проблему без использования библиотек Apache — расскажите. Конечно, можно было бы сделать всё на сокетах, но это довольно печально.

Итак, мы наконец смогли отобразить главную страницу рутрекера. Казалось бы, победа близко. Как же я ошибался.

Реклама

Практически с первой попытки отладки я столкнулся с тем, что всё чудовищно тормозит. Причина довольно очевидна — безумное количество рекламы всевозможных видов. Мне очень не хотелось с ней что-то делать — все мы знаем положение рутрекера, и чувакам явно нужно много золота для защиты от DDOS'a и родного государства — но с рекламой приложением было пользоваться вообще нереально. Правильным решением было бы находить её и вырезать из тела страницы, но более быстрым для реализации подходом было просто порезать её по хостам:

довольно очевидный код

    public static boolean is_adv(Uri url) {
        String[] adv_hosts = {"marketgid.com", "adriver.ru", "thisclick.network", "hghit.com",
                "onedmp.com", "acint.net", "yadro.ru", "tovarro.com", "marketgid.com", "rtb.com", "adx1.com",
                "directadvert.ru", "rambler.ru"};
        String[] adv_paths = {"brand", "iframe"};
        String host = url.getHost();
        for (String item : adv_hosts) {
            if (StringUtils.containsIgnoreCase(host, item)) {
                return true;
            }
        }
        if (StringUtils.containsIgnoreCase(url.getHost(), "rutracker.org")) {
            String path = url.getPath();
            for (String item : adv_paths) {
                {
                    if (StringUtils.containsIgnoreCase(path, item)) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

И после этого мы просто обрубаем её получение:

        if (Utils.is_adv(url)) {
            Log.d("WebView", "Not fetching advertisment");
            return new WebResourceResponse("text/javascript", "UTF-8", null);
        }

Задним умом я только что подумал, что возможно было бы проще сделать список разрешённых хостов… Но этим самым умом все сильны.
Теперь приложение стало работать не то что с приемлимой, а с очень бодрой и приятной скоростью. Если владельцев рутрекера это огорчит, то блокировку уберу — но скорее всего вместе с приложением. С чудовищными тормозами в нём просто не будет смысла.

Отправка форм

Довольно откинувшись на стул, я обнаружил… Что не работает авторизация. Что было на самом деле весьма очевидно — поскольку в перехватываемом мной и передаваемом далее запросе я не отправлял данные, которые должны уйти в POST. Казалось бы, пара минут — и всё будет хорошо. Как же я ошибался…
Выяснилось, что способов перехватить POST из WebView нету. Совсем. Никак. Лучшие рекомендованные практики — внедрять в страницу свой javascript и вызывать из него специальные Java методы. Или переводить сервер на GET запросы. От первого варианта мне несколько поплохело, а второй недоступен по понятным причинам. Да и был бы некорректен. Почесав голову и попробовав отловить POST ещё в нескольких местах, я пришёл к выводу, что нормального решения всё же нет. В результате сделал решение смешное. А именно — при получении страницы менять метод всех форм с POST на GET. А после этого при следующем обращении конвертировать переданные в адресной строке параметры в тело POST запроса. Звучит ужасно, но всё не так плохо, если у вас нет адресной строки, в которой можно опозориться, больших переменных или файлов для передачи. Хотя нет, вру конечно. Всё очень плохо, но другого адекватного пути я не нашёл.

довольно очевидный код

    public static UrlEncodedFormEntity get2post(Uri url) {
        Set<String> params = url.getQueryParameterNames();
        if (params.isEmpty())
            return null;

        List<NameValuePair> paramsArray = new ArrayList<>();

        for (String name : params) {
            Log.d("Utils", "converting parameter " + name + " to post");
            paramsArray.add(new BasicNameValuePair(name, url.getQueryParameter(name)));
        }
        try {
            return new UrlEncodedFormEntity(paramsArray, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return null;
    }

Где мои куки, чувак?

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

Скрытый текст

            if (Utils.is_login_form(url)) {

                Header[] all = response.getAllHeaders();
                for (Header header1 : all) {
                    Log.d("WebView", "LOGIN HEADER: " + header1.getName() + " : " + header1.getValue());

                }
                Header[] cookies = response.getHeaders("set-cookie");
                if (cookies.length > 0) {
                    String val = cookies[0].getValue();
                    val = val.substring(0, val.indexOf(";"));
                    authCookie = val.trim();
                    CookieManager.put(MainContext, authCookie);
                    Log.d("WebView", "=== Auth cookie: ==='" + val + "'");
                    //redirect does not work o_O
                    String msgText = "<script>window.location = "http://rutracker.org/forum/index.php"</script>";
                    ByteArrayInputStream msgStream = new ByteArrayInputStream(msgText.getBytes("UTF-8"));
                    return new WebResourceResponse("text/html", "UTF-8", msgStream);

                }
            }

Правда, внимательный читатель обнаружит, что там идёт какая-то странная переадресация яваскриптом. Да, всё так. В ответе должен был быть 302 заголовок авторизации, но почему-то его никуда не приходило. В результате я оказывался на странице форума по некорректному адресу с доменом login.rutracker.org — всё работало, но, поскольку ссылки везде относительные, при следующем же клике наступал облом. Кстати, здесь же можно заметить, что пользовательскую куку мы бережно сохраняем, чтобы не пришлось потом заново авторизоваться.

Теперь-то всё?!

Именно с этим вопросом я в который раз открывал страничку, уже уверенный, что ничего не упустил. Как же я… В общем, можно было свободно искать через нативный поиск или через гугловый без авторизации, можно было смотреть любую тему, нельзя было только одно… Скачать торрент. Поскольку по нажатию ссылок ничего хорошего не происходит. Но в этот раз всё тоже было несложно — в интерфейс добавилась программная менюшка с кнопочкой Share, которая позволяет отправить куда угодно Magnet ссылку. Было бы удобнее кидать ссылку на торрент файл, но он у вас явно будет блокирован. Конечно, можно было бы скачивать торрент файл и передавать его через шаринг — но это как-нибудь в другой раз.

Осталось только опубликовать

На основании тех странных android приложений, которые я выкладывал раньше, у меня сложилось ощущение, что они пропускают вообще всё. Так что я с лёгким сердцем отправил приложение на публикацию и сел писать статью. Обычно приложение появлялось на google play в течении пары часов, так что времени как раз хватало. К сожалению, в этот раз прошло 8 часов. И в ответ пришло

вот такое письмо.

After review, Rutracker free — unofficial, ru.jehy.proxy_rutracker, has been suspended and removed from Google Play as a policy strike because it violates the impersonation policy.
Next Steps

  1. Read through the Impersonation article for more details and examples of policy violations.
  2. Make sure your app is compliant with the Impersonation and Intellectual Property policy and all other policies listed in the Developer Program Policies. Remember additional enforcement could occur if there are further policy issues with your apps.
  3. Sign in to your Developer Console and submit the policy compliant app using a new package name and a new app name.

What if I have permission to use the content?

Contact our support team to provide a justification for its use. Justification may include providing proof that you are authorized to use the content in your app or some other legal justification.Additional suspensions of any nature may result in the termination of your developer account, and investigation and possible termination of related Google accounts. If your account is terminated, payments will cease and Google may recover the proceeds of any past sales and/or the cost of any associated fees (such as chargebacks and transaction fees) from you.If you’ve reviewed the policy and feel this suspension may have been in error,please reach out to our policy support team. One of my colleagues will get back to you within 2 business days.

Не совсем понятно, кто кого имперсонифицировал, но у меня было три варианта, что же не понравилось Google:

  1. Что я взял откуда-то иконки;
  2. Что я упомянул в комментариях, что приложение работает через google compression proxy;
  3. Что в качестве банеров я использовал варианты смешных логотипов рутрекера с нового конкурса;

Конечно, в любом случае довольно неприятно, что нужно зачем-то переименовывать pakage и закачивать его заново. Да тебя ещё и предупреждают, что «я тебя запомнил, и ещё раз так сделаешь — забаню». Ну как-то по гопнически. Не ожидал, прям обидно.
Из упомянутых выше вариантов я решил, что скорее всего виноват второй — упоминание Google всуе. Ну ладно, как скажете — не стал писать, как работает приложение. Просто «приложение с проксированием, которое позволяет обходить блокировку rutracker.org». Ну и заодно поставил кривые нарисованные за пять минут иконки и аналогичные банеры. И что вы думаете — мои усилия были вознаграждены! Дальше мне пришёл

следующий ответ.

After review, Rutracker free, ru.jehy.rutracker_free, has been suspended and removed from Google Play as a policy strike because it violates the webviews and affiliate spam policy.

Next Steps

Read through the Webviews and Affiliate Spam article for more details and examples of policy violations.
Make sure your app is compliant with the Spam policy and all other policies listed in the Developer Program Policies. Remember that additional enforcement could occur if there are further policy issues with your apps.
If it’s possible to bring your app into compliance, you can sign in to your Developer Console and submit the policy compliant app using a new package name and a new app name.

В общем, меня обвинили в том, что не то приложение только реферральные ссылки передаёт, то ли ничего кроме вебвьюва не делает. Ничего «левого» в приложении нет, а делает оно весьма некислый функционал. Ну и особенно это меня удивило в свете того, что я уже успешно закачивал на Google Play приложения, которые фактически только состоят из вебвью на сайт. Нюансов было два — в приложении была авторизация, и я был хозяином сайта, на который шёл вебвью. Но я это никак не указывал, и Google узнать этого не мог.
В общем, на оба этих обвинения я ответил просьбой разобраться — сутки ответа нет, может быть ответ придёт ещё через сутки. Хотя надежды как-то мало. Так что ставим пока что приложение из APK. Если оно таки появится на Google Play, то можно будет обновиться оттуда.

ToDo

Если делать полноценное приятное приложение, то можно было бы добавить много всего хорошего. В том числе

  1. Стили с адаптацией к просмотру с телефона и планшета;
  2. Корректное вырезание рекламы;
  3. Передачу торрентов торрент файлами, а не magnet ссылками;
  4. Выход из авторизованного состояния на рутрекере (да, сейчас ты там авторизуешься навсегда);
  5. Какие-то осмысленные сообщения о вероятных ошибках;
  6. Совместимость с большим количеством устройств — сейчас можно попробовать запустить на Android от 4.0 до 6 — но результат непредсказуем — надо много тестировать. У меня работает на Nexus 5 с Android 6 и на Sony Xperia Z3 с Android 5;
  7. Удобный ввод авторизации и поиска без того, чтобы тыкать на ужасные маленькие элементы веб формы;
  8. Убрать из кода некоторое количество копи-пейста;
  9. Добавить шифрование хранимой пользовательской куки уникальным для устройства ключом на случай, если данные с телефона украдут;
  10. Реализовать монетизацию приложения, свои всплывающие банеры и ссылки, которые принесли бы мне тонны золота.

Но, к сожалению, у меня нет времени этим серьёзно заниматься — хотелось просто сделать некий работающий прототип — чтобы показать, что веб приложения в приложениях Android работают, работают хорошо и быстро. Попутно, правда, подтвердилось мнение о том, что среднее по больнице качество разработки на Android довольно сильно страдает, стандартные библиотеки не покрывают всех кейсов, и есть большое количество странных задач, которые никем толково не реализованы.
Буду рад, если кто-то возьмёт этот код для разработки более серьёзного приложения или же зашлёт пулл реквестов — обещаю их внимательно отсматривать и применять.

Q&A

— Но ведь гораздо проще зайти на рутрекер через ХХХ (например, просто включив экономию трафика на телефоне или в браузере).
— Да.

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

— А если рутрекер заблокирует гугл за большое количество заходов с их прокси?
— К ним и так сейчас через неё ходят, просто используя хром. Так что вряд ли. И вообще, банить всех подряд — плохая идея, так можно и чиновником стать.

— Ваш код ужасен!
— Да, я уже упоминал, что я не Java разработчик.

— У меня не заработало.
— Да, это общая проблема Android — полноценное приложение надо тестировать в 10 раз дольше, чем писать. Увы, у меня такой возможности не было. Присылайте ошибки, пулл реквесты — поправим.

— А можно вообще так использовать Google Compression Proxy?
— Скоро узнаем — забанят приложение или нет.

— Я не доверяю вам, наверняка вы крадёте все мои пароли и скачиваете себе мои торренты.
— Пожалуйста — соберите приложение себе из исходников.

— А почему иконки такие страшные?
— Посмотрите под заголовком «осталось только опубликовать» — там всё объясняется.

— Это же раздолье для злых роботов и пауков!
— Да нет, не обольщайтесь. С очевидностью, там тоже есть свои лимиты и проверки на роботов. Есть гораздо более простые пути для ботов.

— Сайт криво отображается.
— Да, местами есть проблемы. Но связанные скорее с некачественной вёрсткой.

Привет рутрекеру

Отдельно — несколько пожеланий для администрации рутрекера на случай, если вдруг они сюда заглянут

хотелки

  1. Пожалуйста, подумайте о альтернативных вариантах монетизации. Всё равно большинство пользователей обходят эти жуткие гирлянды банеров адблоком.
  2. Было бы хорошо внедрить различных провайдеров авторизации — гуглового или фейсбучного, например.
  3. Обновите вёрстку и внешний вид до чего-то более функционального и желательно с мобильной версией.

Ссылки

  1. Актуальные исходники и релизы на гитхабе;
  2. Тут можно взять меня на работу — да, я её ищу;
  3. Забавная статья о том, как использовать Google Compression Proxy+Squid. С неё я и начинал;
  4. Тут лежат исходники от compression proxy от гугла. Поскольку google code не даёт их посмотреть, можно посмотреть на моём гитхабе. Его я и разбирал;
  5. Что такое google compression proxy для пользователя и для администратора;
  6. Хороший ответ и разбор стандарта google compression proxy, правда только для HTTP траффика, не HTTPS. Есть пример реализации для firefox. Его я и переводил для статьи;
  7. Реализация прокси на Java, которая проксирует через google data compression server. Сделано для какого-то image board. Хороший код, есть что посмотреть;
  8. Единственный найденный мной вариант по проксированию HTTP трафика через HTTPS прокси при помощи библиотек apache. Его я и дорабатывал.

Автор: jehy

Источник


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


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