Директен VPN тунел между компютри чрез NAT на доставчици (без VPS, използвайки STUN сървър и Yandex.disk)

Разширение статии за това как успях да организирам директен VPN тунел между два компютъра, разположени зад ISP NAT. Последната статия описва процеса на организиране на връзка с помощта на трета страна - посредник (нает VPS, действащ като нещо като STUN сървър и възлов предавател на данни за връзка). В тази статия ще ви разкажа как успях без VPS, но посредниците останаха и те бяха сървърът STUN и Yandex.Disk ...
Директен VPN тунел между компютри чрез NAT на доставчици (без VPS, използвайки STUN сървър и Yandex.disk)

въведение

След като прочетох коментарите на последния пост, разбрах, че основният недостатък на внедряването е използването на посредник - трета страна (VPS), която посочва текущите параметри на възела, къде и как да се свържете. Предвид препоръките за използване на истински STUN (от които има много), за да определите текущите настройки на връзката. Първо, реших да използвам TCPDump, за да разгледам съдържанието на пакетите, когато STUN сървърът работеше с клиенти и получи напълно нечетливо съдържание. Попаднах на протокола в гугъл статия, описваща протокола. Разбрах, че не мога сам да внедря заявка към STUN сървъра и оставих идеята.

теория

Наскоро трябваше да инсталирам STUN сървър на Debian от пакета

# apt install stun-server

и в зависимостите видях пакета stun-client, но някак си не придадох никакво значение на това. Но по-късно си спомних пакета stun-client и реших да разбера как работи, като потърсих в Google и използвах Yandex, получих:

# apt install stun-client
# stun stun.ekiga.net -p 21234 -v

В отговор получих:

STUN клиент версия 0.97
Отворен порт 21234 с fd 3
Отворен порт 21235 с fd 4
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 0

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 4

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 2

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Получено съобщение за зашеметяване: 92 байта
MappedAddress = <Моят IP>:2885
Адрес на източника = 216.93.246.18:3478
Променен адрес = 216.93.246.17:3479
Неизвестен атрибут: 32800
Име на сървъра=Vovida.org 0.98-CPC
Получено съобщение от тип 257 id=1
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 0

Предстои да изпрати съобщение от len 28 до 216.93.246.17:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 4

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 2

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 0

Предстои да изпрати съобщение от len 28 до <Моят IP>:2885
Получено съобщение за зашеметяване: 28 байта
ChangeRequest = 0
Получено съобщение от тип 1 id=11
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 0

Предстои да изпрати съобщение от len 28 до 216.93.246.17:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 4

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 2

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Получено съобщение за зашеметяване: 92 байта
MappedAddress = <Моят IP>:2885
Адрес на източника = 216.93.246.17:3479
Променен адрес = 216.93.246.18:3478
Неизвестен атрибут: 32800
Име на сървъра=Vovida.org 0.98-CPC
Получено съобщение от тип 257 id=10
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 4

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 2

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 4

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 2

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 4

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 2

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 4

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 2

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 4

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
Кодиране на съобщение за зашеметяване:
Encoding ChangeRequest: 2

Предстои да изпрати съобщение от len 28 до 216.93.246.18:3478
тест I = 1
тест II = 0
тест III = 0
тест I(2) = 1
е nat = 1
съпоставен IP същия = 1
фиби = 1
резервен порт = 0
Основно: независимо картографиране, зависим от порта филтър, произволен порт, ще се закрепи
върнатата стойност е 0x000006

Низ със стойност

MappedAddress = <Моят IP>:2885

точно това, от което се нуждаете! Той показва текущото състояние на връзката на локалния UDP порт 21234. Но това е само половината от битката, възникна въпросът как да прехвърлите тези данни на отдалечен хост и да установите VPN връзка. Използвайки пощенския протокол, или може би Telegram?! Има много опции и реших да използвам Yandex.disk, както попаднах статия за работата на Curl чрез WebDav с Yandex.disk. След като помислих за изпълнението, измислих следната схема:

  1. Сигнализира, че възлите са готови да установят връзка чрез наличието на конкретен файл с клеймо за време на Yandex.disk;
  2. Ако възлите са готови, вземете текущите параметри от STUN сървъра;
  3. Качете текущите настройки на Yandex.disk;
  4. Проверка за наличие и четене на параметрите на отдалечен хост от файл на Yandex.disk;
  5. Установяване на връзка с отдалечен хост чрез OpenVPN.

Практика

След кратък размисъл, като взех предвид опита от последната статия, набързо написах сценарий. Ще ни трябва:

# apt install openvpn stun-client curl 

Всъщност самият скрипт:

оригинална версия

# cat vpn8.sh

#!/bin/bash
######################## Задаем цветной текст ###
WARN='33[37;1;41m'				#
END='33[0m'					#
RED='33[0;31m'         #  ${RED}		#
GREEN='33[0;32m'      #  ${GREEN}		#
#################################################
####################### Проверяем наличие необходымих приложений #########################################################
al="echo readlink dirname grep awk md5sum shuf nc curl sleep openvpn cat stun"
ch=0
for i in $al; do which $i > /dev/null || echo -e "${WARN}Для работы необходим $i ${END}"; which $i > /dev/null || ch=1; done
if (( $ch > 0 )); then echo -e "${WARN}Ой, отсутствуют необходимые для корректной работы приложения${END}"; exit; fi
#######################################################################################################################

if [[ $1 == '' ]]; then echo -e "${WARN}Введите идентификатор соединения (любое уникальное слово, должно быть одинаковое с двух сторон!) ${END} t
${GREEN}Для запуска в автоматическом режиме при включении компьютера можно прописать в /etc/rc.local строку nohup /<путь к файлу>/vpn8.sh  > /var/log/vpn8.log 2>/dev/hull & ${END}"; exit; fi
ABSOLUTE_FILENAME=`readlink -f "$0"`                                                    # полный путь до скрипта
DIR=`dirname "$ABSOLUTE_FILENAME"`                                                      # каталог в котором лежит скрипт
############################### Проверка наличия секретного ключа ##################################
key="$DIR/secret.key"
if [ ! -f "$key" ]; then
				echo -e "${WARN}Секретный ключ VPN-соединения не найден, для генерации ключа выполните: 
openvpn --genkey --secret secret.key Внимание: ключ используется для авторизации и должен 
быть одинаковым с двух сторон!!!${END}
 # ls -l secret.key
 -rw------- 1 root root 637 ноя 27 11:12 secret.key
 # chmod 600 secret.key";
				exit;
				fi
########################################################################################################################

ABSOLUTE_FILENAME=`readlink -f "$0"`                                                    # полный путь до скрипта
DIR=`dirname "$ABSOLUTE_FILENAME"`                                                      # каталог в котором лежит скрипт
name=$(uname -n | md5sum | awk '{print $1}')
vpn=$(echo $1 | md5sum | awk '{print $1}')
stun="stun.ekiga.net" 	# STUN сервер
username="Yandex"	# Логин от Яндекс.диска	
password="Password"	# Пароль от Яндекс.диска
localport=`shuf -i 20000-65000 -n 1`	# генерация локального порта

echo "$(date) Создаю папку на Яндекс.диске"
curl -X MKCOL --user "${username}:${password}" https://webdav.yandex.ru/vpn-$vpn
echo "$(date) Очищаю папку от всякого мусора"
for i in `curl --silent --user "$username:$password" -X PROPFIND -H "Depth: 1" https://webdav.yandex.ru/vpn-$vpn/ | sed 's/></n/g' | grep "d:displayname" | sed 's/d:displayname//g' | sed 's/>//g' | sed 's/<//' | sed 's////g' | grep -v $(date +%Y-%m-%d-%H-%M)`; do
	echo "$(date) Delete: $i"
	curl -X DELETE --user "${username}:${password}" https://webdav.yandex.ru/vpn-$vpn/$i
	done

until [ $c ];do

	until [[ $b ]]; do
		echo "$(date) Проверяю папку"
		date=`date +%Y-%m-%d-%H-%M`
		mydata=`curl --silent --user "${username}:${password}" -X PROPFIND -H "Depth: 1" https://webdav.yandex.ru/vpn-$vpn/ | sed 's/></>n</g' | grep $name | grep $date | grep "d:displayname"`
		if [[ -z $mydata ]]; 	then
						echo "$(date) Файл готовности создан"
					        echo "$date" > "/tmp/$date-$name-ready.txt"
					        curl -T "/tmp/$date-$name-ready.txt" --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/$date-$name-ready.txt
					else
						echo "$(date) Файл готовности уже существует - $date"
					fi
		remote=`curl --silent --user "${username}:${password}" -X PROPFIND -H "Depth: 1" https://webdav.yandex.ru/vpn-$vpn/ | sed 's/></>n</g' | grep -v $name | grep $date | grep "d:displayname"`
		if [[ -z $remote ]];	then
						echo -e "$(date) ${RED} Удаленный узел не готов ${END}"
						echo "$(date) Жду"
						sleep 20
					else
						echo -e "$(date) ${GREEN} Удаленный узел готов ${END}"
						b=1
						a=''
					fi
	done

	until [ $a ]; do
		echo "$(date) Подключение и получение данных от STUN сервера: $stun"
                mydata=`stun $stun -p $localport -v 2>&1 | grep MappedAddress | sort | uniq`
                echo -e "$(date) ${GREEN}Мои данные соединения: $mydata${END}"
                echo "$mydata" > "$DIR/mydata"
                echo "$(date) Загрузка данных на Яндекс.диск"
                curl -T "$DIR/mydata" --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/$name.txt
		echo "$(date) Получение файла данных удаленного узла"
		filename=$(curl --silent --user "${username}:${password}" -X PROPFIND -H "Depth: 1" https://webdav.yandex.ru/vpn-$vpn/ | sed 's/></n/g' | grep "d:displayname>" | grep "txt" | grep -v "$name" | grep -v "ready" | sed 's|.*d:displayname>||' | sed 's/</ /g' | awk '{print $1}')
		echo "$(date) Чтение файла данных удаленного узла: $filename"
		address=$(curl --silent --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/$filename | sort | uniq | head -n1 | sed 's/:/ /g')
		echo "$(date) Определение IP-адреса и порта"
		ip=$(echo "$address" | awk '{print $3}')
		port=$(echo "$address" | awk '{print $4}')
		if [[ -n "$ip" && -n "$port" ]]; then
			echo -e "$(date) ${GREEN} Соединение $ip $port ${END}"
       		 	openvpn --remote $ip --rport $port --lport $localport 
	       	 	    --proto udp --dev tap --float --auth-nocache --verb 3 --mute 20 
	       	 	    --ifconfig 10.45.54.2 255.255.255.252 
	       		    --secret "$DIR/secret.key" 
	       		    --auth SHA256 --cipher AES-256-CBC 
	        	    --ncp-disable --ping 10  --ping-exit 30 
	        	    --comp-lzo yes
			echo -e "$(date) ${WARN} Соединение разорвано${END}"
			a=1
			b=''
			else
			a=1
			b=''
			fi
	done
done

За да работи скриптът, трябва:

  1. Копирайте в клипборда и поставете в редактора, например:
    # nano vpn8.sh 
  2. посочете данните за вход и парола от Yandex.disk.
  3. в полето "—ifconfig 10.45.54.(1 или 2) 255.255.255.252" посочете вътрешния IP адрес на интерфейса
  4. създавам таен ключ с командата:
    # openvpn --genkey --secret secret.key 
  5. направете скрипта изпълним:
    # chmod +x vpn8.sh
  6. стартиране на скрипт:
    # ./vpn8.sh nZbVGBuX5dtturD

    където nZbVGBuX5dtturD е генерираният идентификатор на връзката тук

На отдалечения хост направете същото, с изключение на генерирането на secret.key и ID на връзката, те трябва да са идентични.

Актуализирана версия (за коректна работа времето трябва да е синхронизирано):

cat vpn10.sh

#!/bin/bash
stuns="stun.sipnet.ru stun.ekiga.net"   		# Список STUN серверов через пробел
username=" Login "		# Логин от Яндекс.диска
password=" Password "   	# Пароль от Яндекс.диска
intip="10.23.22.1"		# IP-адрес внутреннего интерфейса
WARN='33[37;1;41m'
END='33[0m'
RED='33[0;31m'
GREEN='33[0;32m'
al="ip echo readlink dirname grep awk md5sum openssl sha256sum shuf curl sleep openvpn cat stun"
ch=0
for i in $al; do which $i > /dev/null || echo -e "${WARN}Для работы необходим $i ${END}"; which $i > /dev/null || ch=1; done
if (( $ch > 0 )); then echo -e "${WARN}Ой, отсутствуют необходимые для корректной работы приложения${END}"; exit; fi
if [[ $1 == '' ]];
then
echo -e "${WARN}Введите идентификатор соединения (любое уникальное слово, должно быть одинаковое с двух сторон!) ${END} t
${GREEN}Для запуска в автоматическом режиме при включении компьютера можно прописать в /etc/rc.local строку nohup /<путь к файлу>/vpn10.sh  > /var/log/vpn10.log 2>/dev/hull & ${END}"
exit
fi
ABSOLUTE_FILENAME=`readlink -f "$0"`                                                    # полный путь до скрипта
DIR=`dirname "$ABSOLUTE_FILENAME"`                                                      # каталог в котором лежит скрипт
key="$DIR/secret.key"
until [[ -n "$iftosrv" ]]
do
echo "$(date) Определяю сетевой интерфейс"; iftosrv=`ip route get 8.8.8.8 | head -n 1 | sed 's|.*dev ||' | awk '{print $1}'`
sleep 5
done
timedatectl
name=$(uname -n | md5sum | awk '{print $1}')
vpn=$(echo $1 | md5sum | awk '{print $1}')
echo "$(date) Создаю папку на Яндекс.диске"
curl -X MKCOL --user "${username}:${password}" https://webdav.yandex.ru/vpn-$vpn
echo "$(date) ID на диске: $vpn"
until [ $c ];do
echo "$(date) Очищаю папку от всякого мусора"
for i in `curl --silent --user "$username:$password" -X PROPFIND -H "Depth: 1" https://webdav.yandex.ru/vpn-$vpn/ | sed 's/></n/g' | grep "d:displayname" | sed 's/d:displayname//g' | sed 's/>//g' | sed 's/<//' | sed 's////g' | grep -v $(date +%Y-%m-%d-%H-%M)`
do
echo -e "$(date)${RED} Удаляю старый файл: $i${END}"
curl -X DELETE --user "${username}:${password}" https://webdav.yandex.ru/vpn-$vpn/$i
done
echo "$(date) ID на диске: $vpn"
openvpn --genkey --secret "$key"
passwd=`echo "$vpn-tt" | sha256sum | awk '{print $1}'`
openssl AES-256-CBC -e -in "$key" -out "$DIR/file.enc" -k "$passwd" -base64
curl -T "$DIR/file.enc" --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/key.enc
rm "$DIR"/file.enc
echo -e "$(date) ${GREEN}Фаза 1 - Получение готовности удаленного узла${END}"
go=3
localport=`shuf -i 20000-65000 -n 1`    # генерация локального порта
start=''
remote=''
timeout1=''
nextcheck=''
timestart=''
until [[ $b ]]
do
echo "$(date) Проверяю папку"
date=`date +%s`
timeout1=60
echo "$(date) Создание файла готовности $date"
echo "$date" > "/tmp/ready-$date-$name.txt"
curl -T "/tmp/ready-$date-$name.txt" --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/ready-$name.txt
readyfile=`curl --silent --user "${username}:${password}" -X PROPFIND -H "Depth: 1" https://webdav.yandex.ru/vpn-$vpn/ | sed 's/></>n</g' | grep -v $name | grep "ready" | grep "d:displayname" | sed 's/<d:displayname>//g' | sed 's/</d:displayname>//g'`
if [[ -z $readyfile ]]
then
echo -e "$(date) ${RED} Удаленный узел не готов ${END}"
echo "$(date) Жду 60 секунд"
sleep $timeout1
else
remote=$(curl --silent --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/$readyfile)
echo -e "$(date) ${GREEN} Удаленный узел готов ${END}"
start=`curl --silent --user "${username}:${password}" -X PROPFIND -H "Depth: 1" https://webdav.yandex.ru/vpn-$vpn/ | sed 's/></>n</g' | grep "start" | grep "d:displayname" | sed 's/-/ /g' | awk '{print $2}'`
if [[ -z $start ]]
then
let nextcheck=$timeout1-$date+$remote
let timestart=$date+$timeout1-$nextcheck
go=$nextcheck
echo "$timestart" > "/tmp/start-$date-$name.txt"
curl -T "/tmp/start-$date-$name.txt" --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/start-$date-$name.txt
else
echo "$(date) жду $go секунд"
sleep $go
b=1
a=''
fi
fi
done
echo -e "$(date) ${GREEN}Фаза 2 - Обмен данными и установка соединения${END}"
mydata=''
filename=''
address=''
myip=''
ip=''
port=''
ex=0
until [ $a ]; do
until [[ -n "$mydata" ]]; do
k=`echo "$stuns" | wc -w`
x=1
z=`shuf -i 1-$k -n 1`
for st in $stuns; do
if [[ $x == $z ]]; then
stun=$st;
fi;
(( x++ ));
done
echo "$(date) Подключение и получение данных от STUN сервера: $stun"
sleep 5 && for pid in $(ps xa | grep "stun "$stun" 1 -p "$localport" -v" | grep -v grep | awk '{print $1}'); do kill $pid; done &
mydata=`stun "$stun" 1 -p "$localport" -v 2>&1 | grep "MappedAddress" | sort | uniq`
done
echo -e "$(date) ${GREEN}Мои данные соединения: $mydata${END}"
echo "$(date) Загрузка данных на Яндекс.диск"
echo "$mydata" > "$DIR/mydata"
echo "IntIP $intip" >> "$DIR/mydata"
curl -T "$DIR/mydata" --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/$name-ipport.txt
rm "$DIR/mydata"
sleep 5
echo "$(date) Получение файла данных удаленного узла"
filename=$(curl --silent --user "${username}:${password}" -X PROPFIND -H "Depth: 1" https://webdav.yandex.ru/vpn-$vpn/ | sed 's/></n/g' | grep "d:displayname>" | grep "ipport" | grep -v "$name" |  sed 's|.*d:displayname>||' | sed 's/</ /g' | awk '{print $1}')
if [[ -n "$filename" ]]
then
echo "$(date) Чтение файла данных удаленного узла: $filename"
address=$(curl --silent --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/$filename | grep "MappedAddress" | head -n1 | sed 's/:/ /g')
intip2=$(curl --silent --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/$filename | grep "IntIP" | head -n1 | awk '{print $2}')
echo "$(date) Определение IP-адреса и порта: $address $sesid2 $tunid2"
ip=$(echo "$address" | awk '{print $3}')
port=$(echo "$address" | awk '{print $4}')
myip=`ip route get "$ip" | head -n 1 | sed 's|.*src ||' | awk '{print $1}'`
if [[ -n "$ip" && -n "$port" && -n "$myip" && -n "$localport" ]];
then
echo -e "$(date) ${GREEN} Соединение $ip $port ${END}"
echo -e  "`date` ${GREEN} $myip:$localport -> $ip:$port ${END}"
curl --silent --user "$username:$password" https://webdav.yandex.ru/vpn-$vpn/key.enc > "$DIR/secret.enc"
openssl AES-256-CBC -d -in "$DIR/secret.enc" -out "$key" -k "$passwd" -base64
chmod 600 "$key"
rm "$DIR/secret.enc"
openvpn --remote $ip --rport $port --lport $localport 
--proto udp --dev tun --float --auth-nocache --verb 3 --mute 20 
--ifconfig "$intip" "$intip2" 
--secret "$key" 
--auth SHA256 --cipher AES-256-CBC 
--ncp-disable --ping 10 --ping-exit 20 
--comp-lzo yes
a=1
b=''
fi
else
if (( $ex >= 5 ))
then
echo "$(date) Сброс"
a=1
b=''
fi
(( ex++ ))
sleep 5
fi
done
done

За да работи скриптът, трябва:

  1. Копирайте в клипборда и поставете в редактора, например:
    # nano vpn10.sh 
  2. посочете данните за вход (2-ри ред) и парола от Yandex.disk (3-ти ред).
  3. посочете вътрешния IP адрес на тунела (4-ти ред).
  4. направете скрипта изпълним:
    # chmod +x vpn10.sh
  5. стартиране на скрипт:
    # ./vpn10.sh nZbVGBuX5dtturD

    където nZbVGBuX5dtturD е генерираният идентификатор на връзката тук

На отдалечения хост направете същото, посочете подходящия IP адрес на вътрешния тунел и ID на връзката.

За да стартирам автоматично скрипта при стартиране, използвам командата "nohup /<път към скрипта>/vpn10.sh nZbVGBuX5dtturD > /var/log/vpn10.log 2>/dev/null &", съдържаща се във файла /etc/rc .местен

Заключение

Скриптът работи, тестван на Ubuntu (18.04, 19.10, 20.04) и Debian 9. Можете да използвате всяка друга услуга като предавател, но за опит използвах Yandex.disk.
По време на експериментите беше установено, че някои видове NAT доставчици не ви позволяват да установите връзка. Предимно от мобилни оператори, където торентите са блокирани.

Планирам да се подобря по отношение на:

  • Автоматично генериране на secret.key всеки път, когато стартирате, криптиране и копиране в Yandex.disk за предаване към отдалечен хост (Разглежда се в актуализираната версия)
  • Автоматично присвояване на IP адреси на интерфейса
  • Криптиране на данни преди качване в Yandex.disk
  • Оптимизация на кода

Нека във всеки дом има IPv6!

Обновено! Най-новите файлове и DEB пакет тук - yandex.disk

Източник: www.habr.com

Добавяне на нов коментар