python

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

  • воскресенье, 18 октября 2015 г. в 02:10:53
http://habrahabr.ru/post/268993/

Проект был написан скорее в учебных целях (научиться сетевому программированию в 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 сокет потокового типа
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


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