На Хабре нередко появляются статьи типа "я хотел настроить <черный ящик>, не углубляясь в доки подергал за рычаги и у меня всё получилось, сейчас я научу вас как надо". Не смотря на неэкспертный технический уровень они нередко вызывают интерес и бурные дискуссии. Поэтому я решил вам рассказать про сисадмина Васю, который решил настроить себе двухзвенный VPN из подручных средств не выходя далеко за пределы Хабра.

Вася любил читать хабр, и прочитав про ocserv немедленно скачал, поставил и настроил. И работал он у него на всех доступных устройствах и блокировки всегда обходили его стороной. Но когда в новостях написли что вот-вот заблокируют вообще всё, Вася задумался о двухзвенной схеме, которую опытные эксперты на хабре называли перспективной. При этом Вася не хотел переходить на новое ПО, потому, что везде где надо всё уже привычно стояло и работало, и хотелось, в случае необходимости, по-быстренькому поменять адреса и продолжать работать, а не искать, ставить, настраивать и привыкать заново. Кроме того, все известные Васе модные vpn протоколы периодически блокировали, дорабатывали, снова блоркировали и снова дорабатывали, и так по кругу, а ocserv работал себе потихоньку и не подводил ни разу.

В голове сразу возник план будущего проекта:

Рис. 1
Рис. 1

и вот, сидя на выходных дома с простудой, Вася приступил к работе. Скачал свежие исходники ocserv с gitlab установил в /opt/ocserv, получил ssl сертификат от Let's Encrypt, и нарисовал базовый конфиг:

ocserv.cfg
auth = "plain[passwd=/opt/ocserv/etc/ocserv.passwd]"
listen-host =  <адрес входного сервера>
udp-listen-host = 127.0.0.1   # отключаем DTLS чтобы меньше быть похожими на vpn
ipv4-network = 172.16.20.0/24 # диапазон клиентских адресов
route = default               # нужно для клиентов, не нужно для выходного сервера
tcp-port = 443
udp-port = 443
server-cert = /etc/letsencrypt/live/<DNS имя входного сервера>/fullchain.pem
server-key =  /etc/letsencrypt/live/<DNS имя входного сервера>/privkey.pem
dh-params = /etc/cron.d/dh.pem
use-occtl = true
occtl-socket-file = /run/occtl.socket
socket-file = /run/ocserv-socket
device = biteme
predictable-ips = true
compression = false
tls-priorities = "PERFORMANCE:%SERVER_PRECEDENCE:%COMPAT:-VERS-ALL:+VERS-TLS1.2:-ARCFOUR-128"
max-ban-score = 50
ban-reset-time = 300
ban-points-wrong-password = 10
ban-points-connection = 1
camouflage = true
camouflage_secret = "<секретный код>"
camouflage_realm = "Please Login."

Подключившись к входному серверу с выходного сервера и с домашнего компьютера получил два новых интерфейса:

Фрагмент вывода ip a
3: biteme0: <POINTOPOINT,UP,LOWER_UP> mtu 1472 qdisc fq_codel state UNKNOWN group default qlen 500
    link/none
    inet 172.16.20.1 peer 172.16.20.2/32 scope global biteme0
       valid_lft forever preferred_lft forever
4: biteme1: <POINTOPOINT,UP,LOWER_UP> mtu 1472 qdisc fq_codel state UNKNOWN group default qlen 500
    link/none
    inet 172.16.20.1 peer 172.16.20.3/32 scope global biteme1
       valid_lft forever preferred_lft forever

Дело за малым, осталось зарутить всё с biteme1 на biteme0 и всех делов. Но, повозившись, Вася обнаружил, что номера, имена и адреса интерфейсов зависят от порядка подключения, и, прежде чем маршрутизировать, нужно понять куда и откуда.

Чтобы получить фиксированный адрес клиента пришлось выйти за пределы хабра, но ненадолго, и в итоге Вася добился фиксированного адреса для клиента с выходного сервера:

ocserv.conf
...
ipv4-network = 172.16.20.128/25
route = 172.16.20.0/24
config-per-user = /opt/ocserv/etc/config-per-user/
...
/opt/ocserv/etc/config-per-user/exitserver
ipv4-network = 172.16.20.0/30
 route = 172.16.20.0/24

Ocserv не позволяет задавать фиксированные адреса клиентам, но задав маску посети /30 можно оставить два бита для адреса, тогда адрес 1 (01) будет использован для серверного интефейса, адрес 2 (10) - для клиентского. После этих дополнений выходной сервер стал всегда получать адрес 172.16.20.2, а клиентские устройства - адреса в диапазоне 172.16.20.130..255.

С маршрутизацией всё оказалось сложнее. Пушистые пакетики с домашнего кома бодро рутились на входной сервер, но там, потерявших берега и ориентиры, их уносило в дикий интернет через default route. Не долго думая Вася сделал:

ip route add <адрес входного сервера> via <default route входного сервера>
ip route add <адрес выходного сервера> via <default toute выходного сервера>
ip route replace default via 172.16.20.2 #vpn адрес на выходном сервере

И всё сразу заработало (потому, что маскарадинг на выходном сервере у Васи уже был настроен). Но возникла очевидная проблема: теперь к входному серверу можно подключиться только с адресов для которых прописаны статические маршруты. Одевшись, Вася пошел прогуляться (не за пивом, а за ингалиптом и нозанексом), думая по дороге, что можно бы завернуть ocserv в docker, но это уже несколько черезчур, и, внезапно вспомнил, что когда-то читал на хабре статью про network namespaces. Немедленно возник план: создаем network namespace, в нем стартуем ocserv, в нем-же прописываем default gateway и таким образом настоящий default gateway не пострадает. А как же попадать в network namespace снаружи? Конечно через nginx reverse proxy. Красиво? Нет. Криво? Да. Но бросать начатое, и почти готовое, на полпути не хотелось, поэтому, прежде чем вздохнуть и сделать по уму, Вася решил довести дело до конца. Ну, или тупика.

Конфиг nginx получился лаконичным:

nginx.conf
include /etc/nginx/modules-enabled/*.conf;
events {
        worker_connections 30;
}
stream {
    upstream ocserv {
        server 192.168.0.2:443; # адрес в network namespace
    }
    server {
        listen <адрес входного сервера>:443; # смотрит в дикий интернет
        proxy_pass ocserv;
        proxy_protocol on;
    }
}

Адрес 192.168.0.2 - это адрес сетевого интерфейса в network namespace в котором будет запущен ocserv. Сам ocserv запускается юнитом:

ocserv.service
[Unit]
Description=OpenConnect SSL VPN server
Documentation=man:ocserv(8)
After=network-online.target

[Service]
PrivateTmp=true
PIDFile=/run/ocserv.pid
Type=simple
ExecStartPre=/opt/ocserv/netnsctl start
ExecStart=ip netns exec ocservns /opt/ocserv/sbin/ocserv --foreground --pid-file /run/ocserv.pid --config /opt/ocserv/etc/ocserv.conf
ExecStopPost=/opt/ocserv/netnsctl stop
ExecReload=/opt/ocserv/bin/occtl reload

[Install]
WantedBy=multi-user.target

Скрипт для создания/удаления network namespace Вася скопипастил из упомянутой статьи, подправил и получилось следующее:

netnsctl
#!/bin/bash
set -e
export nsname=ocservns

netns_use() {
    echo Use: $0 '<create|delete>'
    exit
}
netns_create () {
    ip netns add ${nsname}
    ip netns exec ${nsname} ip link set dev lo up
    ip link add ${nsname}p type veth peer name ${nsname}c
    ip link set ${nsname}c netns ${nsname}
    ip addr add 192.168.0.1/24 dev ${nsname}p
    ip link set dev ${nsname}p up
    ip netns exec ${nsname} ip addr add 192.168.0.2/24 dev ${nsname}c
    ip netns exec ${nsname} ip link set dev ${nsname}c up
    ip netns exec ${nsname} ip route add default via 192.168.0.1
}
netns_delete () {
    ip netns del $i
}
case  "$1" in
    create) netns_create
        ;;
    delete) netns_delete
        ;;
    *)      netns_use
        ;;
esac

Чтобы всё заработало осталось включить proxy protocol в ocserv и добавить default route через 172.16.20.2. Поскольку ocserv создает сетевые интерфейсы только при подключении клиентов, пришлось добавить скрипт, запускаемый при подключении клиента с выходного сервера:

ocserv-connect
#!/bin/bash
#Следующая строка не нужна, но будет работать правильно, потому что используется proxy protocol
#echo "$(date) [info] User ${USERNAME} Connected - Server: ${IP_REAL_LOCAL} VPN IP: ${IP_REMOTE} Remote IP: ${IP_REAL} Device:${DEVICE}" >> /var/log/ocserv.log
if [ ${IP_REMOTE} == "172.16.20.2" ]
then
  /usr/sbin/ip netns exec ocservns ip route add default via 172.16.20.2
fi

Чувствуя себя художником, Вася внес последние штрихи:

ocserv.conf
auth = "plain[passwd=/opt/ocserv/etc/ocserv.passwd]"
listen-host = 192.168.0.2 #адрес в network namespace
udp-listen-host = 127.0.0.1
ipv4-network = 172.16.20.128/25
route = 172.16.20.0/24
route = default
listen-proxy-proto = true # для работы через proxy и верного отображения ip клиентов
config-per-user = /opt/ocserv/etc/config-per-user/  # конфигурация юзера выходного сервера
connect-script = /opt/ocserv/bin/ocserv-connect # для установки default route
#disconnect-script = /opt/ocserv/scripts/ocserv-disconnect
listen-proxy-proto = true
tcp-port = 443
udp-port = 443
server-cert = /etc/letsencrypt/live/<DNS имя входного сервера>/fullchain.pem
server-key =  /etc/letsencrypt/live/<DNS имя входного сервера>/privkey.pem
dh-params = /etc/cron.d/dh.pem
use-occtl = true
occtl-socket-file = /run/occtl.socket
socket-file = /run/ocserv-socket
device = biteme
predictable-ips = true
compression = false
tls-priorities = "PERFORMANCE:%SERVER_PRECEDENCE:%COMPAT:-VERS-ALL:+VERS-TLS1.2:-ARCFOUR-128"
max-ban-score = 50
ban-reset-time = 300
ban-points-wrong-password = 10
ban-points-connection = 1
camouflage = true
camouflage_secret = "<секретный код>"
camouflage_realm = "Please Login."

После этого осталось запустить сервер и клиентов и посмотреть как это работает. Поскольку входной серер васполагался примерно по пути от домашнего компа до выходного сервера, ping roundtrip time вырос менее чем на <=10% по сравнению с vpn соединеним через openconnect и zerotier. А при скачивании freebsd.iso с mirror.yandex.ru скорость упиралась в 100Мбит - скорость интерфейса на выходном сервере, поэтому при всех недостатках сетап оказался вполне рабочим, Вася почувствовал себя лучше и простуда с насморком сразу отступили.