Удалённое исполнение системных команд по запросу через сокеты на Python 3 или как я сайты скачивал

    Проект был написан скорее в учебных целях (научиться сетевому программированию в Python), чем в практических. Такую же роль несёт и статься, ведь сейчас вряд ли кто-то будет скачивать сайты, чтобы прочитать пару статеек (за исключением некоторых случаев, когда подобное реально может пригодится).

    Не так давно качество мобильного интернета в моём городе стало постепенно ухудшаться из-за возрастающей на сети операторов нагрузки и некоторые сайты, требующие большое количество соединений (зависимые файлы страницы) стали загружаться ну ОЧЕНЬ медленно. По вечерам скорость опускается на столько, что некоторые сайты могут полностью загружаться в течении нескольких десятков секунд.

    Есть несколько способов решения данной проблемы, но я решил выбрать немного необычный для нашего времени способ. Я решил скачивать сайты. Конечно, данных способ не подходит для крупных сайтов, вроде Хабра, тут разумнее использовать парсер, но можно скачать и отдельный хаб, список пользователей, или только свои публикации с помощью HTTrack Website Copier, применив фильтры. Например, чтобы скачать хаб Python с Хабра нужно применить фильтр "+habrahabr.ru/hub/python/*".

    Этот способ можно использовать ещё в нескольких целях. Например, чтобы скачать сайт, или его часть, перед тем, как вы окажитесь без интернет-соединения, например, в самолёте. Или для того, чтобы скачать заблокированные на территории РФ сайты, если скачивать их через Tor, что будет очень медленно, или через компьютер в другой стране, где данных сайт не запрещён, а потом передать его на компьютер, находящийся в РФ, что будет гораздо быстрее для многостраничных сайтов. Таким образом мы может скачать, например, xHamster Wikipedia через сервер в Германии или Нидерландах и получить сайт в сжатом виде по SFTP, FTP, HTTP или другому, удобному для вас, протоколу. Если, конечно, места хватит, для такого большого сайта :)

    Ну что, начнём!? Приложение будет постепенно усложнятся и в него будет добавляться всё новых функционал, это позволит понять что вообще здесь происходит и как это всё работает. Код я буду сопровождать большим достаточным количеством комментариев, чтобы его мог понять даже человек, не знающий Python, но повторно комментировать уже описанные куски кода и функции не буду, дабы не загромождать код. И сервер и клиент пишутся и проверяются под Linux, но, теоретически, должны работать и под другими платформами, если установлены все необходимые приложения, а именно: httrack и tar, а так же выставлен необходимый путь в конфигурационном файле, который мы создадим ниже. Если у вас появятся проблемы с запускам под вашей платформой, пишите в комментариях

    Для начала реализуем простой сервер который будет пересылать строку клиенту.

    # FILE: server.py
    import socket
    
    # Создаём IPv4 сокет потокового типа (TCP/IPv4)
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # Связываем сокет с адресом localhost и портом 65042
    sock.bind(("localhost", 65042))
    # Начинаем слушать
    sock.listen(True)
    # По мере поступления
    while True:
        # При присоединении клиента создаём две переменные для управления соединения с клиентом и адрес этого клиента
        conn, addr = sock.accept()
        # Выводим адрес этого клиента
        print('Connected by', addr)
        # Читаем переданных клиентом данные, но не более 1024 байт
        data = conn.recv(1024)
        # Отправляем клиенту полученную от него же строку
        conn.sendall(data)
    

    Теперь реализуем ещё более простой клиент, который будет выводить принятую (то есть отправленную им же серверу) строку.

    # FILE: client.py
    import socket
    
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # Присоединяемся к серверу
    sock.connect(("localhost", 65042))
    sock.sendall(b"Hello, world")
    # Читаем данные от сервера, но не более 1024 байт
    data = sock.recv(1024)
    # Закрываем соединение
    sock.close()
    # Выводим полученные данные
    print(data.decode("utf-8"))
    

    При выводе мы использовали метод decode(original) чтобы получить из массива байт строку. Чтобы расшифровать массив байт нужно указать кодировку, в нашем случае это UTF-8.

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

    Так как мы мы планируем использовать наше приложение изредка, то с удобством можно особо не париться. Что же должно уметь делать наше приложение? В первую очередь, это скачивать сайты. Хорошо, серверное приложение скачало наш сайт, что теперь? Нам ведь хочется его посмотреть, ведь так? Для этого нужно его передать с серверной машины на клиентскую, а так как количество файлов очень большое, а со временем установления соединения у нас большие проблемы, то неплохо было ещё и упаковать всё это, желательно ещё и хорошенько сжать. Ну и неплохо было бы иметь возможность просмотреть скаченные сайты, но об этом чуть позже.

    Команды, передаваемые серверу, будут иметь следующий формат:
    <command> [args]
    

    Например:
    dl site.ru 0 gz
    list
    list during
    

    Для начала немного модифицируем наш клиент. Заменим
        sock.sendall(b"Hello, world")
    

    на
        sock.sendall(bytes(input(), encoding="utf-8"))
    

    Теперь мы можем передавать на сервер произвольные команды, введённые с клавиатуры.

    Перейдём к серверу, тут всё посложнее.

    Для начала создадим два файла: httrack.py и config.py. Первый будет содержать функции для управления HTTrack, второй — конфигурацию для клиента и сервера (он будет общим). При желании можете сделать конфигурационный файл для сервера и клиента раздельным и использовать не питоновский формат, а конфигурационный .ini, или что-то подобное.

    Со вторым файлом всё просто и понятно:
    from os import path
    
    host = 'localhost'
    port = 65042
    # Путь для скачивания сайтов. На данный момент - директория <b>Sites</b> в домашнем каталоге. Измените значение, если данный путь вам не подходит, рекомендуется указать пустую или несуществующую директорию.
    sites_directory = path.expanduser("~") + "/Sites"
    

    Перед тем как перейти к первому файлу, немного расскажу про функцию call из стандартной библиотеки subprocess.
    subprocess.call(args)
    

    Функция исполняет команду, переданную в массиве args. Эта функция так же может принимать параметр cwd, задающий каталог, в котором следует выполнить команду из массива args. Ждёт завершения исполняемой команды (вызванной программы) и возвращает код завершения.

    Теперь напишем нашу, пока единственную, функцию управления HTTrack'ом, позволяющую скачивать сайт в нужную нам директорию:
    # FILE: httrack.py
    from subprocess import call
    from os import makedirs
    
    # Файл с конфигурацией в директории с проектом
    import config
    
    def download(url):
        # Избавляемся от указанного протокола (в строке, разумеется), если он есть.
        if url.find("//"):
            url = url[url.find("//")+2:]
        # И от завершающего слэша
        if url[-1:] == '/':
            url = url[:-1]
        site = config.sites_directory + '/' + url
        print("Downloading ", url, " started.")
        # Создаём папку, в которую будут скачиваться все сайты
        makedirs(config.sites_directory, mode=0o755, exist_ok=True)
        # Вызываем HTTrack в нужной нам директории
        call(["httrack", url], cwd=config.sites_directory)
        print("Downloading is complete")
    

    Изменим server.py:

    import socket
    import threading
    
    # Файлы в директории с проектом
    import httrack
    import config
    
    def handle_commands(connection, command, params):
        if command == "dl":
            # Создание отдельного треда (потока, процесса, если хотите) для HTTrack'а
            htt_thread = threading.Thread(target=httrack.download, args=(params[0]))
            # и его запуск
            htt_thread.start()
            connection.sendall(b'Downloading has started')
        else:
            connection.sendall(b"Invalid request")
    
    def args_analysis(connection, args):
        # Разбиваем строку на массив на каждом пробеле. Например "dl site.ru 0 gz" превратится в ["dl", "site.ru", "0", "gz"].
        args = args.decode("utf-8").split()
        # [1:] - срез. В данном случае, с первого до последнего элемента.
        handle_commands(connection=connection, command=args[0], params=args[1:])
    
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.bind((config.host, config.port))
    sock.listen(True)
    while True:
        conn, addr = sock.accept()
        print('Connected by ', addr)
        data = conn.recv(1024)
        args_analysis(connection=conn, args=data)
    

    Тут, я думаю, всё понятно. Код немного усложнён функциями, которые можно объединить и тем самым упростить код, но они помогут нам в последующих изменениях кода.

    На данный момент можем запустить сначала server.py, а затем client.py. В клиентском приложении вводим следующую команду:
    dl http://verysimplesites.co.uk/
    

    Примерно через минуту, в зависимости от вашего интернет-соединения, серверное приложение выведет "Downloading is complete" и у вас в домашнем каталоге появиться папка Sites, а в ней каталог verysimplesites.co.uk, в котором уже лежит скаченный сайт, который можно открыть в браузере без интернет-соединения.

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

    Функция проверки статуса процесса httrack в server.py:
    def dl_status_checker(thread, connection):
        if thread.isAlive:
            connection.sendall(b'Downloading has started')
        else:
            connection.sendall(b'Downloading has FAILED')
    

    Команда dl в server.py:
    if command == "dl":
            # Флаг удаления директории поднят, если аргумент не равен <b>"0"</b>
            if params[1] == '0':
                params[1] = False
            else:
                params[1] = True
            # Если директорию удалять мы не собираемся и формат архива мы не передали, то упаковывать мы не будем
            if not params[1] and len(params) == 2:
                params.append(None)
            htt_thread = threading.Thread(target=httrack.download, args=(params[0], params[1], params[2]))
            htt_thread.start()
            # Через 2 секунды проверить, работает ли всё ещё HTTrack
            dl_status = threading.Timer(2.0, dl_status_checker, args=(htt_thread, connection))
            dl_status.start()
    

    httrack.py:
    from subprocess import call
    from os import makedirs
    from shutil import rmtree
    
    import config
    
    def download(url, remove, archive_format):
        if url.find("//"):
            url = url[url.find("//")+2:]
        if url[-1:] == '/':
            url = url[:-1]
        site = config.sites_directory + '/' + url
        print("Downloading ", url, " started.")
        makedirs(config.sites_directory, mode=0o755, exist_ok=True)
        call(["httrack", url], cwd=config.sites_directory)
        print("Downloading is complete")
        if archive_format:
            if archive_format == "gz":
                # Например: <b>tar -czf /home/user/Sites/site.ru.tar.gz -C /home/user/Sites /home/user/Sites/site.ru</b>
                call(["tar", "-czf", config.sites_directory + '/' + url + ".tar.gz",
                      "-C", config.sites_directory, url], cwd=config.sites_directory)
            elif archive_format == "bz2":
                call(["tar", "-cjf", config.sites_directory + '/' + url + ".tar.bz2",
                      "-C", config.sites_directory, url], cwd=config.sites_directory)
            elif archive_format == "tar":
                call(["tar", "-cf", config.sites_directory + '/' + url + ".tar",
                      "-C", config.sites_directory, url], cwd=config.sites_directory)
            else:
                print("Archive format is wrong")
        else:
            print("The site is not packed")
        if remove:
            rmtree(site)
            print("Removing is complete")
        else:
            print("Removing is canceled")
    

    Появилось много нового кода, но ничего сложного в нём нет, просто появилось несколько новых условий. Из новых функций появилась только rmtree, которая удаляет переданную ей директорию, включая всё, что находилось в последней.

    Можно добавить в функцию handle_commands простую команду list без параметров:
    elif command == "list":
            # Получаем список файлов и директорий в директории с сайтами
            file_list = listdir(config.sites_directory)
            folder_list = []
            archive_list = []
            # Проверяем в цикле, есть ли у нас директории или архивы, содержащие сайты
            for file in file_list:
                if path.isdir(config.sites_directory + '/' + file) and file != "hts-cache":
                    folder_list.append(file)
                if path.isfile(config.sites_directory + '/' + file) and \
                        (file[-7:] == ".tar.gz" or file[-8:] == ".tar.bz2" or file[-5:] == ".tar"):
                    archive_list.append(file)
            site_string = ""
            folder_found = False
            # Проверка на пустоту массива
            if folder_list:
                site_string += "List of folders:\n" + "\n".join(folder_list)
                folder_found = True
            if archive_list:
                if folder_found:
                    site_string += "\n================================================================================\n"
                site_string += "List of archives:\n" + "\n".join(archive_list)
            if site_string == "":
                site_string = "Sites not found!"
            connection.sendall(bytes(site_string, encoding="utf-8"))
    

    Подключив в начале необходимую библиотеку:
    from os import listdir, path
    

    Ещё неплохо было бы увеличить максимальный размер принимаемых клиентом данных от сервера в client.py:
        data = sock.recv(65536)
    

    Теперь перезапустим server.py и запустим client.py. Для начала прикажем скачать серверу какой-нибудь сайт и упаковать его в tar.gz архив, после чего удалить:
    dl http://verysimplesites.co.uk/ 1 gz
    

    После этого скачаем другой сайт, но упаковывать его не будем, удалять, разумеется, тоже:
    dl http://example.com/ 0
    

    И спустя примерно минуту проверим список сайтов:
    list
    

    Если вы вводили те же команды, то должны получить следующий ответ от сервера:
    List of folders:
    example.com
    ================================================================================
    List of archives:
    verysimplesites.co.uk.tar.gz
    


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

    UPD: Часть 2. Протокол передачи данных.
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 12
    • 0
      # Принимаем переданных клиентом данные, но не более 1024 байт
      data = conn.recv(1024)

      Скорее принимаем первые 1024 байта. Это работа с сырыми сокетами и команда, как я понимаю является прямым биндингом к recv в C.
      Думаю в python есть более высокоуровневые решения.
      Поправьте, если ошибаюсь.
      • 0
        А вообще жду на github. Так и читать код приятнее будет и может кто-то что-то захочет добавить.
        • 0
          Завтра собираюсь отрефакторить и залить, и, возможно, подготовить немного кода для следующей статьи, преимущественно про защиту.
          • 0
            Как и обещал, написал продолжение статьи и выложил код на GitGub.
          • 0
            Да, всё верно. Данная команда является просто связкой с системными сокетами и читает первые 1024 (или меньше) байт от ещё не прочитанных. Правильнее было бы читать их небольшими кусками в цикле, но, так как данные небольшие, можно просто прочитать первые 1024 байт. Сомневаюсь, что кому-то потребуется скачать сайт, у которого адрес больше тысячи символов длиной.
            • 0
              Бывают ссылки с трекингом googe analytics(utm=....). Они бывает и в 2к символов не укладываются. Плюс еще utf… Ну в общем вы поняли к чему я.
              • 0
                В следующей статье добавлю несколько вспомогательных функций для сокетов. В общем, следующая статья будет больше не на добавление функционала, а на исправление недостатков и дыр приложения.
              • 0
                Фокусы начнутся при плохом интернет соединении и нетерпеливом пользователе, который быстро пошлет 2 команды, а у вас на сервере они соединятся в 1, так как TCP это поток данных, он не разделяет сообщения.

                Либо на оборот получение одной команды разобьется на «2 разных сообщения».
            • 0
              А почему не использовали библиотеку?

              docs.python-requests.org/en/latest
              • +1
                Во-первых, это не стандартная библиотека, а меня интересовали именно встроенные в Python средства для сетевого программирования. Во-вторых, что самое главное, предназначение у этой библиотеки другое, что понятно даже по названию.
                Feature Support
                Requests is ready for today’s web.

                International Domains and URLs
                Keep-Alive & Connection Pooling
                Sessions with Cookie Persistence
                Browser-style SSL Verification
                Basic/Digest Authentication
                Elegant Key/Value Cookies
                Automatic Decompression
                Unicode Response Bodies
                Multipart File Uploads
                Connection Timeouts
                .netrc support
                Python 2.6—3.4
                Thread-safe.
              • 0
                Специально для тех, кто придёт из гугла по запросу «скачать сайт» напоминаю про отличный многопоточный инструмент скачивания страниц/сайтов: github.com/binux/pyspider
                Он не из простых, но за час можно вполне освоится со всеми фишками и успешно скачать сайт.
                • +1
                  Это, конечно, далеко всё, что можно и нужно реализовать, но, тем не менее

                  Думаю вы имели ввиду «далеко не все» :) Исправте! А так, статья самое то для познания python, хорошо разжевана!

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.