В статье рассматривается настройка OpenVPN c дополнительными фичами:

  • сертификаты на токенах для первичной аутентификации (на примере Rutoken)
  • LDAP-бекенд для вторичной аутентификации (на примере ActiveDirectory)
  • фильтрация внутренних ресурсов, доступных для пользователяx (через iptables)

Так же описана настройка клиентов под Linux, Windows и MacOS.

Настройка сервера


Установка OpenVPN


Взять скрипт Nyr/openvpn-install, запустить от root.

git clone https://github.com/Nyr/openvpn-install.git
cd openvpn-install

В процесс запуска будет задано несколько вопросов.

  • протокол udp
  • порт 1194
  • DNS-сервера — локальные
  • external ip — адрес шлюза в интернете, через который будет доступен vpn-сервер

Так же существует улучшенная в плане безопасности версия исходного скрипта — github.com/Angristan/OpenVPN-install. В ней больше настроек шифрования с пояснениями почему так.

Управление пользователями


Добавление
В случае, если не используются токены, добавление пользователя осуществляется через тот же скрипт. Скрипт по сути генерит пользовательский ovpn-конфиг и вставляет туда сертификат, подписанный корневым сертификатом.

Если используются токены (см. ниже раздел про токены) тогда сертификат выписывается руками на основе запроса на сертификат, который генерится на токене. Пользовательский конфиг надо делать руками из имеющегося шаблона (из того же самого, из которого генерит конфиг скрипт). Шаблон лежит тут /etc/openvpn/client-common.txt. Он не входит в поставку openvpn и генерится скриптом в процессе настройки.

Удаление
Удаление пользователей производится через тот же скрипт установки. Сертификат добавляется в CRL, новый CRL подпихивается vpn-серверу. Все сертификаты, которые есть в CRL сервер считает недействительными и принимать отказывается.

Как отозвать сертификат вручную:

cd /etc/openvpn/easyrsa

# отозвать сертификат
./easyrsa revoke $CLIENT
# сгенерировть новый crl
./easyrsa gen-crl

# удалить старый crl
rm -rf /etc/openvpn/crl.pem
# подменить его новым
cp /etc/openvpn/easy-rsa/pki/crl.pem /etc/openvpn/crl.pem
# openvpn должен уметь читать crl, когда он уже дропнул привилегии до nobody
chown nobody:nobody /etc/openvpn/crl.pem

Фильтрация доступных хостов для клиентов


Клиентов необходимо ограничивать по тем хостам на которые им можно ходить внутри сети, когда они подключаются к openvpn.

Вручную

Идея в том, чтобы ловить пакеты еще на интерфейсе tun0, в который они приходят от клиентов и фильтровать их до того как они попадают в NAT. После NAT фильтровать их будет уже не почему — у них у всех будет ip-адрес openvpn сервера во внутренней сети. До того как попасть в NAT, пакеты у каждого пользователя имеют свой уникальный ip-адрес (соответствие ip-адресов и пользователей можно посмотреть в файле /etc/openvpn/ipp.txt).

Пакеты, которые проходят сквозь систему (не исходят непосредственно из нее и не являются входящими, т.е. по сути роутятся системой) обрабатываются таблицей FORWARD. Таблицы в iptables обрабатываются сверху вниз, если ни одно из правил в таблице не привело к решению судьбы пакета, тогда срабатывает дефолтное правило.

Подготовка таблицы FORWARD:

# сбросить все
iptables -F FORWARD
# дефолтное правило для таблицы FORWARD - не пропускать ничего
iptables -P FORWARD DROP
# пропускать уже установленные соединения
iptables -I FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT

Пример правил для конкретного клиента. Поскольку дефолтное правило для таблицы — DROP, осталось только разрешить те пары хост+порт, куда можно. Разрешить доступ к порту на хосте + пинговать сам хост:

iptables -I FORWARD -s 10.8.0.3 -i tun0 -d 10.0.2.3  -p tcp --dport 443 -j ACCEPT
iptables -I FORWARD -s 10.8.0.3 -i tun0 -d 10.0.2.3 -p icmp --icmp-type echo-request -j ACCEPT

В примере выше хосту 10.8.0.3 разрешается доступ к порту 443 хоста 10.0.2.3.

Как доступ закрыть:

# показать правила в таблице с указанием их номеров
iptables -L FORWARD --line-numbers
# удаление правила по номеру
iptables -D FORWARD {номер правила}

Потом надо найти все правила для конкретного клиента и удалить их.

Во время отладки удобно смотреть на то, какие правила срабатывают. У каждого правила есть счетчик обработанных пакетов.

# показывать счетчики, с обновлением каждые две секунды
watch iptables -nvL FORWARD
# сбросить счетчики в нули
iptables -Z FORWARD

Автоматически

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

  • common_name (имя владельца сертификата; то что вбивается в поле common name при создании сертификата)
  • ifconfig_pool_remote_ip (ip-адрес клиента на tun0)
  • script_type (какое именно событие произошло — подключение или отключение).

Чтобы управлять iptables необходимы привилегии root. Openvpn после подключения сбрасывает права до nobody и от него же выполняет скрипты. Плохо позволять nobody что-то делать из-под sudo, да и звездочкой в правилах лучше не пользоваться, но как-то нужно разрешить пользователю управлять iptables.

# /etc/sudoers.d/50_openvpn
#
# разрешить добавлять правила
nobody ALL = NOPASSWD: /sbin/iptables -A FORWARD*
# разрешить просматривать список правил
nobody ALL = NOPASSWD: /sbin/iptables -L FORWARD*
# разрешить удалять правила
nobody ALL = NOPASSWD: /sbin/iptables -D FORWARD*

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

script-security 2
client-connect    /etc/openvpn/bin/hosts.rb
client-disconnect /etc/openvpn/bin/hosts.rb

Сам скрипт, который читает конфиги и применяет правила для iptables. Скрипт работает по тем же принципам, которые описаны в предыдущем разделе.

/openvpn/bin/hosts.rb
#!/usr/bin/ruby
# -*- coding: utf-8 -*-
require 'pp'

def log(string)
  puts 'hosts.rb: ' + string
end

def parse_config_file(name)
  config_path = "hosts/#{name}"

  unless File.exist?(config_path)
    puts "There is no specific configuration for #{name}."
    p name
    exit 0
  end

  config_source = IO.read(config_path).split("\n")

  config = config_source.inject([]) do |result,line|
    ip, port, protocol = line.split(/\s+/)
    result << {
      ip: ip,
      port: port,
      protocol: protocol || 'tcp'
    }
  end
end

def get_config(name)
  user_config = parse_config_file(name)
  if user_config
    everybody_config = parse_config_file('everybody')
  end
  everybody_config + user_config
end

def apply_rule(rule)
  command = "sudo iptables #{rule}"
  log(command)
  system(command)
end

def remove_rule(number)
  command = "sudo iptables -D FORWARD #{number}"
  log(command)
  system(command)
end

def allow_target(source_ip, options)
  # Разрешить для клиента доступ к конкретному порту конкретного хоста.
  apply_rule("-A FORWARD -s #{source_ip} -i tun0 -d #{options[:ip]} -p #{options[:protocol]} --dport #{options[:port]} -j ACCEPT")
  # Разрешить для клиента пинговать конкретный хост
  apply_rule("-A FORWARD -s #{source_ip} -i tun0 -d #{options[:ip]} -p icmp --icmp-type echo-request -j ACCEPT")
end

def clear_targets(source_ip)
  # Удалить все правила из таблицы FORWARD, содержащие source_ip.

  rules_exist = true

  while rules_exist

    table = `sudo iptables -L FORWARD --line-number`.split("\n")

    the_line = table.find do |line|
      fields = line.split(/\s+/)
      ip = fields[4]
      ip == source_ip
    end

    if the_line
      number = the_line.split(/\s+/)[0]
      remove_rule(number)
    else
      rules_exist = false
    end

  end

end

################################################################################

script_type = ENV['script_type']
log(script_type)

name      = ENV['common_name']
source_ip = ENV['ifconfig_pool_remote_ip']

case script_type
when 'client-connect'
  config = get_config(name)
  config.each{|target| allow_target(source_ip, target)}
when 'client-disconnect'
  clear_targets(source_ip)
else
  puts "Unknown script type #{script_type}."
end

Правила хранятся в файлах, соответствующими common name сертификатов в папке /etc/openvpn/hosts. В них прописаны какие именно IP-адреса доступны для конкретного клиента. Разделитель — произвольное количество пробелов. Через разделитель записываются ip-адрес, порт и протокол (tcp или udp).

10.0.0.24  53 udp
10.0.0.25  53 udp
10.0.2.3  443 tcp

В результате в папке /etc/openvpn должна получиться следующая структура

+-- bin
¦ L-- hosts.rb
+-- hosts
¦ +-- user1
¦ +-- user2
¦ L-- everybody
+-- server.conf
L--…

User1 и user2 — это файлы в вышеприведенном формате. Они описывают к каким хостам у пользователя с соответствующим common name есть доступ.

Есть еще один дополнительный файл everybody, в нем лежат правила, _которые применяются ко всем клиентам_, при условии, что для этих клиентов есть отдельный файл конфигурации. То есть, если для пользователя указан список хостов, куда ему можно ходить, тогда применяется этот список и те хосты, которые перечислены в everybody. Если нет, тогда everybody не применяется. В этот файл удобно выносить например DNS-сервера.

Логирование

Скрипт установки включает только логирование текущих соединений (параметр status). Чтобы появился обычный лог нужно дописать строчку в конфиг сервера (/etc/openvpn/server.conf):
log-append /var/log/openvpn.log


LDAP

Существует плагин openvpn-auth-ldap, который позволяет вторично аутентифицировать пользователя через LDAP.

Поставить пакет:

sudo yum install openvpn-auth-ldap

Добавить в server.conf:

plugin /usr/lib64/openvpn/plugin/lib/openvpn-auth-ldap.so "/etc/openvpn/ldap.conf"

Создать конфиг для ldap в /etc/openvpn/ldap.conf:
<LDAP>
  URL              ldaps://{LDAP_DOMAIN_HERE}
  Timeout          15
  TLSEnable        no
  FollowReferrals  yes

  BindDN           "BIND_DN_HERE"
  Password         "BIND_PASSWORD_HERE"
</LDAP>

<Authorization>
  BaseDN           "{BASE_DN_HERE}"
  SearchFilter     "(&(sAMAccountName=%u)(objectClass=organizationalPerson)(objectCategory=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))"
  RequireGroup     false
</Authorization>

Добавить в пользовательский ovpn-конфиг строчку:

auth-user-pass

Таким образом у пользователя сначала спросят логин и пароль из домена, потом PIN-код от токена. Если один из этих шагов не пройдет, подключение не будет установлено.

Описание опций для ldap.conf есть в репозитории плагина. Он поддерживает аутентификацию по членству в группе, но я это не тестировал.

Скорость


Наибольший прирост скорости дает включение udp режима. Это советуют во всех мануалах. Смысл в том, что нет смысла запускать клиентское tcp-подключение в tcp канале. Одного tcp у клиента достаточно, чтобы производить корректную доставку пакетов. Если в udp-канале будут пропадать пакеты, то корректировку доставки будет контролировать клиентское tcp-соединение.

Скорость возрастет как минимум потому что не надо ждать подтверждения доставки каждого пакета в канале. С tcp есть вторая проблема — один клиентский tcp пакет скорее всего не влезает в один пакет vpn-канала. MTU совпадает, но к клиентскому пакету нужно еще добавлять заголовки. В результате на один пользовательский пакет приходится отсылать два пакета внутри vpn-канала.

TCP имеет смысл использовать когда по-другому нельзя. Например, когда vpn работает через ssh канал.

Пример полного конфига сервера


example-server.conf
port 1194
proto tcp
dev tun
sndbuf 0
rcvbuf 0
ca ca.crt
cert server.crt
key server.key
dh dh.pem
tls-auth ta.key 0
topology subnet
server 10.8.0.0 255.255.255.0
ifconfig-pool-persist ipp.txt
push "redirect-gateway def1 bypass-dhcp"
push "dhcp-option DNS 10.0.0.25"
push "dhcp-option DNS 10.0.0.24"
keepalive 10 120
cipher AES-256-CBC
comp-lzo
user nobody
group nobody
persist-key
persist-tun
status openvpn-status.log
verb 3
crl-verify crl.pem

log-append /var/log/openvpn.log

script-security 2
client-connect    /etc/openvpn/bin/hosts.rb
client-disconnect /etc/openvpn/bin/hosts.rb


Настройка токенов


Библиотека PKCS#11


Для работы с токенами нужна специальная библиотека. Библиотека нужна как для создания ключевых пар, так и для собственно подключения. Скачать под все платформы можно по ссылке.

Везде, где дальше встречается librtpkcs11ecp.so — это и есть та самая библиотека, которую надо скачать и положить куда-нибудь в удобное место.

Создание сертификата на токене


Сгенерировать на токене ключевую пару. Параметр id здесь — это порядковый номер слота на токене, куда укладываются ключевая пара.

pkcs11-tool --module /usr/lib64/librtpkcs11ecp.so --keypairgen --key-type rsa:2048 -l --id 01

Сделать для публичного ключа запрос на сертификат. В процессе создание запроса на сертификат устанавливается срок жизни сертификата и common name, который используется для фильтрации доступных ip-адресов внутри сети. Common name должен соответствовать логину в ActiveDirectory, чтобы не было путаницы.

openssl
openssl> engine -t dynamic -pre SO_PATH:/usr/lib64/openssl/engines/pkcs11.so -pre ID:pkcs11 -pre LIST_ADD:1 -pre LOAD -pre MODULE_PATH:/usr/lib64/librtpkcs11ecp.so
openssl> req -engine pkcs11 -new -key slot_0-id_01 -keyform engine -out /home/john/good.req

Полученный запрос нужно перенести в папку /etc/openvpn/easy-rsa/pki/reqs/. Расширение у файла обязательно должно быть req.
Преобразование запроса в сертификат:

cd /etc/openvpn/easy-rsa/
./easyrsa sign-req client good

После этого в папке /etc/openvpn/easy-rsa/pki/issued/ появится сертификат с тем же именем, но расширением crt.

Перед записью сертификат нужно сконвертировать в DER:

openssl x509 -in /home/user/user-cert.pem -out /home/user/user-cert.crt -outform DER

Запись сертификата на токен:

pkcs11-tool --module /usr/lib/librtpkcs11ecp.so -l -y cert -w /home/user/user-cert.crt --id 45 --label TEST

Написано на основе статьи «Использование Рутокен ЭЦП с OpenSSL (RSA)».

Использование токена для аутентификации


Найти id сертификата, который нужно предъявить серверу:

$ openvpn --show-pkcs11-ids /usr/lib64/librtpkcs11ecp.so

The following objects are available for use.
Each object shown below may be used as parameter to
--pkcs11-id option please remember to use single quote mark.

Certificate
       DN:             /CN=User1
       Serial:         490B82C4000000000075
       Serialized id:  aaaa/bbb/41545F5349474E415455524581D2A1A1B23C4AA4CB17FAF7A4600

Нас здесь интересует serialized id.

Опции, которые надо вписать в ovpn-конфиг, чтобы подцепились токены:

pkcs11-providers /usr/lib64/librtpkcs11ecp.so
pkcs11-id 'aaaa/bbb/41545F5349474E415455524581D2A1A1B23C4AA4CB17FAF7A4600'

Опция pkcs11-id обязательно должна быть заключена в одинарные кавычки.

Эта инструкция имеет смысл на всех платформах. Нужно указать путь до библиотеки и id сертификата на токене. Библиотека может называться немного по-другому, быть .dll, а не .so, но смысл тот же самый.

Из ovpn-файла при этом нужно удалить секции cert и key, потому что сертификат и приватный ключ будут браться с токена.

Полностью клиентский конфиг (для windows) выглядит так:

client.ovpn
client
dev tun
proto tcp
sndbuf 0
rcvbuf 0
remote 78.47.37.247 22222
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
cipher AES-256-CBC
comp-lzo
setenv opt block-outside-dns
key-direction 1
verb 3

pkcs11-providers "c://Windows//System32//rtPKCS11ECP.dll"
pkcs11-id 'Aktiv\x20Co\x2E/Rutoken\x20ECP/342b871d/Rutoken/01'

-----BEGIN CERTIFICATE-----
{CERT_HERE}
-----END CERTIFICATE-----


<tls-auth>
#
# 2048 bit OpenVPN static key
#
-----BEGIN OpenVPN Static key V1-----
{KEY_HERE}
-----END OpenVPN Static key V1-----
</tls-auth>


Написано на основе «How to add dual-factor authentication to an OpenVPN configuration using client-side smart cards».

Настройка клиентов


Linux


В openvpn есть баг, который не дает пользователю ввести PIN-код от токена, если пакет собран с поддержкой systemd. Поскольку в последнее время systemd есть везде, все пакеты, которые уже доступны в репозиториях собраны с его поддержкой. Клиентам на линуксе нужно собирать пакет самостоятельно. Вот пример конфигурации, которая заработала у меня на Arch Linux:

./configure         --prefix=/usr         --sbindir=/usr/bin         --enable-iproute2         --enable-pkcs11         --enable-plugins         --enable-x509-alt-username

Проверить собран openvpn с systemd или без него можно следующей командой:

openvpn --version | grep --color enable_systemd

Mas OS


Под Mac OS есть только один бесплатный клиент — Tunnelblink.

Он не умеет из gui вводить pin-код от токена. Баг описан например здесь — https://groups.google.com/forum/#!topic/tunnelblick-discuss/f_Rp_2nV-x8 Обходится запуском openvpn из консоли. Это не удивительно, учитывая то, что официальный клиент под windows этого тоже не умеет.

Так же под Mac OS (в отличии от windows) необходимы дополнительные скрипты для настройки сети. Если просто запускать openvpn из консоли, то не будет работать DNS (может быть что-то еще, проявился только DNS).

В TunnelBlick есть эти скрипты настройки сети, их только нужно вызвать при установлении и разрыве соединения. Что нужно дописать в ovpn-конфиг:

script-security 2
up   "/Applications/Tunnelblick.app/Contents/Resources/client.up.tunnelblick.sh -9 -d -f -m -w -ptADGNWradsgnw"
down "/Applications/Tunnelblick.app/Contents/Resources/client.down.tunnelblick.sh -9 -d -f -m -w -ptADGNWradsgnw"

Пример скрипта для запуска openvpn-подключения, который можно положить на рабочий стол и тыкать мышкой:

#!/bin/bash

tunnelblick=/Applications/Tunnelblick.app/Contents/Resources/openvpn/openvpn-2.4.2-openssl-1.0.2k
sudo $tunnelblick/openvpn --config $tunnelblick/user.ovpn

Windows


Под windows все вроде работает. Официальный клиент не умеет вводить pin-код от токена, обходится через запуск openvpn руками из консоли.

Самое главное — все делать из под администратора. Запускать от администратора установщик клиента. Запускать терминал в котором стартуется openvpn тоже с правими админа, иначе он не сможет управлять сетевым интерфейсом.

Под виндой путь до библиотеки для работы с токенами должен записываться через двойные слеши. Это касается как ovpn-конфига так и опции --show-pkcs11-ids в командной строке.


pkcs11-providers "c://Windows//System32//rtPKCS11ECP.dll"
pkcs11-id 'Aktiv\x20Co\x2E/Rutoken\x20ECP/342b871d/Rutoken/01'

Комментарии (9)


  1. Barafu_Albino_Cheetah
    15.12.2018 20:46

    Расскажите кто-нибудь, пожалуйста, как на Винде настроить клиент OpenVPN так, чтобы игрушки и некоторые сайты напрямую работали? Иначе приходится вместо OpenVPN всякой неправильной экзотикой пользоваться.


    1. Revertis
      16.12.2018 00:11

      Вычислять айпишники серверов игрушек и добавлять исключения. Проще их пушить с сервера.


    1. ValdikSS
      16.12.2018 02:13

      Скрытый текст
      Маршрутизация, в общем случае, работает на уровне IP-адресов и подсетей, а не на уровне отдельных программ или доменов.
      Сделать доступ через VPN только к отдельным подсетям можно, добавив следующую строку в конфигурационный файл ovpn:
      route-nopull

      Далее добавлять отдельные IP-адреса или диапазоны командой route:
      route network/IP [netmask]


      Пример:
      route-nopull
      route 8.8.8.8 255.255.255.255

      Также можно исключить отдельные диапазоны адресов, чтобы они работали не через VPN.
      Пример:
      route 8.8.8.8 255.255.255.255 net_gateway

      В этом случае добавлять route-nopull не нужно.

      Узнать IP-адреса отдельных доменов можно консольной командой nslookup. Пример:
      $ nslookup ya.ru
      Server:         192.168.101.1
      Address:        192.168.101.1#53
      
      Non-authoritative answer:
      Name:   YA.RU
      Address: 87.250.250.242
      Name:   ya.ru
      Address: 2a02:6b8::2:242

      IPv4-адрес ya.ru — 87.250.250.242.


    1. vviz
      16.12.2018 10:05

      VPN соединие создается, в общем, для доступа к ресурсам находящимся в «серых» сетях. Адресация их известна. Поэтому, на мой взгляд, правильнее указывать маршруты со стороны сервера (OpenVPN имеет довольно развитые возможности для указания клиенту всяких настроек). Т.е. при подключении сервер передает клиенту маршруты на сети, за которые он «отвечает». Клиент применяет эти настройки и получает частные маршруты на указанные сети. Все остальное ходит через шлюз по умолчанию.


  1. nitro2005
    15.12.2018 22:36
    +1

    > С tcp есть вторая проблема — один клиентский tcp пакет скорее всего не влезает в один пакет vpn-канала.

    Это не проблема tcp, это проблема любогл vpn, каким бы он ни был, tcp, udp, ipsec, gre — все они добавляют заголовки и уменьшают mtu.


  1. Arakel
    16.12.2018 01:28

    Смысл в том, что нет смысла запускать клиентское tcp-подключение в tcp канале. Одного tcp у клиента достаточно, чтобы производить корректную доставку пакетов.

    Путаница с моделью OSI. UDP в данном случае используют только потому, что при потере данных их перезапросит протокол более высокого уровня. Разницу в скорости между udp и tcp можно будет увидеть только на сильно загруженной линии.
    Для общего развития статья годная. Для реальной работы есть плагин для openvpn к webadmin.


    1. Andrusha
      16.12.2018 21:02

      webadmin это webmin? Оно, возможно, и есть, но для реальной работы ставить эту навороченую систему на сервер, который выполняет функцию VPN-шлюза, по-моему как раз не имеет смысла. Для управления сервером OpenVPN, если обычная консоль почему-то не нравится, есть заточенные под него решения вроде родного (коммерческий) или Pritunl (FOSS).


  1. mkirya
    16.12.2018 10:12

    В качестве клиента для windows и macOS можно взять наш самодельный www.rutoken.ru/support/download/rutoken-vpn
    Либо viscosity, он очень хороший, но платный.


  1. neumeika
    16.12.2018 19:52

    Заметка тем, кто решится сделать подобное для большого количества пользователей:
    1. -w для iptables
    2. Цепочка на каждого пользователя