Захотелось собрать VPN-комбайн который бы пользователей брал из БД, настраивал фаервол под этого пользователя и писал логи в БД.

OpenVPN на каждое событие (подключение, отключение клиента) может вызывать внешнюю программу. Этим и воспользуемся.

Я использую FreeBSD, но все ниже описанное будет работать на любом Linux, нужно лишь изменить пути. В качестве БД будет выступать Postgresql. Авторизация по сертификату и паролю.
Скрипт взаимодействия с внешними сервисами буду писать на Perl

Создаем БД:

 psql -Upgsql template1
 ctreate database vpn;
 \q

Описание таблиц в БД:

users — из названия понятно что в ней будут пользователи.
столбцы:

  • id
  • login — в моей системе логин цифровой, просто так удобно.
  • name — ФИО пользователя.
  • password — пароль в MD5.
  • groups_id — id группы к которой принадлежит пользователь.
  • active — статус пользователя, по нему определяется может или нет, пользователь подключаться к vpn.
  • hwkey — у меня есть пользователи которые используют eToken, это поле определяет таких. В случае если у пользователя eToken, то проверка по паролю не производится.

groups — таблица с группами

  • id
  • groupname — название группы.
  • active — статус группы, активна или нет.

log — таблица логов, в нее буду писать старт и конец сессии

  • id
  • date — дата
  • users_id — id пользователя
  • realaddress — реальный ip адрес
  • virtualaddress — выданный OpenVPN сервером внутренний адрес
  • action — сюда пишется событие( start/stop)

stat — таблица онлайн пользователей

  • id
  • date — дата подключения
  • name — ФИО пользователя
  • realaddress — реальный ip адрес
  • virtualaddress — адрес выданный OpenVPN сервером

Создаем таблицы:

psql -Upgsql vpn;
create table users (
"id" serial,
"login" varchar(32) not null,
"name" varchar(32) not null,
"password" varchar(32) not null
"groups_id" integer not null,
"active" boolean not null default true,
"hwkey" boolean not null default true,
);

create table groups (
"id" serial,
"groupname" varchar(32) not null,
"active" boolean not null default true
);

create table log (
"id" serial,
"date" timestamp,
"users_id" integer not null,
"realaddress" varchar(32),
"virtualaddress" varchar(32),
"status" varchar(32)
);

create table stat (
"id" serial,
"date" timestamp,
"login" varchar(32);
"realaddress" varchar(32),
"virtualaddress" varchar(32)
);

Создам пользователя с правами чтения для таблиц users и groups, и записью в log и stat

psql -Upgsql template1
 create user ovpn WITH PASSWORD 'password';
\q
psql -Upgsql vpn
grant select on users TO ovpn;
grant select on groups to ovpn;
grant all on log to ovpn;
grant all on stat to ovpn;
\q

Заполнение таблиц:

Для примера, создам две группы, admins с полным доступом к внутренней сети и rdp с доступом к серверам по RDP.

Соответсвенно создам двух пользователей:

100101 — админ
100102 — с доступом к RDP

psql -Upgsql vpn
insert into groups values(dafault,'admins',true);
insert into groups values(default,'rdp',true);
insert into users values(default,100101,'Иванов И.И.',md5('password'),(select id from groups where groupname = 'admins'),true,false);
insert into users values(default,100102,'Петров П.П.',md5('password'),(select id from groups where groupname = 'rdp'),true,false);

В результате, создались две группы с id 1 и 2. В соответствии с этими id будут созданы tables в фаерволе, к которым в свою очередь будут определены соответствующие разрешения.

Сертификаты

Если еще нет корневого сертификата, его нужно создать. У меня, сертификаты находятся в /root/ca

cd /root/ca
openssl req -x509 -newkey rsa:1024 -keyout /root/ca/ssl.key/ca.key -out /root/ca/ssl.crt/ca.crt -days 9999 -nodes  -subj "/C=RU/ST=MSK/L=MSK/O=COMPANY/CN=CA"

Сертификат для сервера подписанный CA сертификатом:

openssl req -new -newkey rsa:1024 -nodes -keyout /root/ca/ssl.key/ovpn.key -subj /CN=ovpn.domain.ru -out /root/ca/ssl.csr/ovpn.csr
openssl ca -config ca.conf -in /root/ssl.csr/ovpn.csr -out /root/ssl.crt/ovpn.crt -batch

ovpn.key, ovpn.crt и ca.crt необходимо скопировать на OpenVPN сервер.

Для генерации пользовательских сертификатов я использую следующий скрипт, он упаковывает ключ и сертификат пользователя в запароленный p12 файл.

#!/usr/bin/perl

use Getopt::Long;

GetOptions ('a=s' => \$action, 'u=s' => \$user, 'O=s' => \$ou, 'o=s'=> \$options, 'help' => sub {HelpMessage()});
# variables
$ca='ca';
$ca_dir='/root/ca/';
$key_id='1000';
if (length($action)==0){
	  HelpMessage();
    exit;
}

if ($action=~/^help$/){
    print "actions:
    adduser   # Add a new User certificate
    gen_revoke    # Generate revoke file\n";
		exit;
}
if ($action=~/^adduser$/){
    adduser();
}
if ($action=~/^gen_revoke$/){
   gen_revoke();
}
sub HelpMessage {
   print "usage: ".$0. " -a <adduser|gen_revoke> -u <login> -O <VPN>\n";
   exit;
}

sub adduser {
   $p12_password = randomPassword(6);
    print "~~Add User(Soft)~~\n"; 
    if (length($user)==0 || length($ou)==0){ 
      print "Error, User and OU must be\n";
      exit;
    }
    # create cert
    print "create User certificate\n";
    system(`openssl req -new -newkey rsa:1024 -nodes -keyout $ca_dir/ssl.key/$user.key -subj /CN=$user/OU=$ou -out $ca_dir/ssl.csr/$user.csr`);
    # sign USER cert
    print "sign certificate\n";
    system(`openssl ca -config ca.conf -in $ca_dir/ssl.csr/$user.csr -out $ca_dir/ssl.crt/$user.crt -batch`);
	  # p12
    print "create  p12\n";
    system(`openssl pkcs12 -export -in $ca_dir/ssl.crt/$user.crt -inkey $ca_dir/ssl.key/$user.key -certfile $ca_dir/ssl.crt/$ca.crt -out p12/$user.p12 -passout	pass:$p12_password`);
   print "p12_password: ".$p12_password."\n";
    # unlink files
    unlink('$ca_dir/ssl.csr/$user.csr','$ca_dir/ssl.crt/$user.crt','$ca_dir/ssl.key/$user.key');
               
}

sub gen_revoke {
   # gen CRL
   system(`openssl ca -config $ca_dir/ca.conf -gencrl -crldays 365 -out $ca_dir/ssl.crl/certPEM.crl`);
   system(`openssl crl -in $ca_dir/ssl.crl/certPEM.crl -outform DER -out $ca_dir/ssl.crl/certDER.crl`);      
   print "pls copy ./ssl.crl/certDER.crl to OpenVPN server\n";
}

sub randomPassword {
        $password;
        $_rand;
        $password_length = $_[0];
        if (!$password_length) {
                  $password_length = 10;
         }
         @chars = split(" ",
                           "A B C D E F G H I J K
                            L M N O P Q R S T U V 
                            W X Y Z a b c d e f g 
                            h i j k l m n o p q r 
                            s t u v w x y z
                           0 1 2 3 4 5 6 7 8 9");

          srand;
          for (my $i=0; $i <= $password_length ;$i++) {
                 $_rand = int(rand 41);
                 $password .= $chars[$_rand];
          }
          return $password;
}

Создадим сертификаты для наших пользователей

/root/ca/gen_cert.pl -a adduser -U 100101 -O VPN
/root/ca/gen_cert.pl -a adduser -U 100102 -O VPN

на выходе получатся два файла /root/ca/p12/100101.p12 и /root/ca/p12/100102.p12 и пароли к ним, которые скрипт напечатает в консоли. Эти файлы нужно будет установить на пользовательских компьютерах (планшетах/телефонах) в защищенное хранилище. Как правило пользователям пароли от контейнеров не сообщаются, это исключает возможность пользователям передавать другим лицам свои ключи.

сразу же можно создать файл отозванных сертификатов crl

/root/ca/gen_cert.pl -a gen_revoke

Его необходимо скопировать на OpenVPN сервер.

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

port 1194
proto udp
dev tun
ca /usr/local/etc/ssl/ca.crt
cert /usr/local/etc/ssl/ovpn.crt
key /usr/local/etc/ssl/ovpn.key
crl-verify        /usr/local/etc/ssl/certPEM.crl
dh /usr/local/etc/ssl/dh2048.pem
tls-verify "/usr/local/etc/openvpn/scripts/intra.pl tls-verefy"
topology subnet
server 172.16.40.0 255.255.255.0
push "route 172.16.0.0 255.255.0.0"
push "dhcp-option DOMAIN domain.local"
push "dhcp-option DNS 172.16.38.10"
client-connect /usr/local/etc/openvpn/scripts/intra.pl
client-disconnect /usr/local/etc/openvpn/scripts/intra.pl
auth-user-pass-verify  "/usr/local/etc/openvpn/scripts/intra.pl auth-user-pass-verify" via-env
keepalive 10 120
persist-key
persist-tun
status /var/log/openvpn-status.log
log         /var/log/openvpn.log
log-append  /var/log/openvpn.log
management localhost 7505
verb 3

Итак, в четырех местах в конфиге вызывается внешний скрипт:

tls-verify параметр который позволяет проверять сертификат клиента на некоторое соответствие. В моем случае я проверяю что в CN записано тоже самое что клиент передает в Login, иначе говоря проверяю чтобы CN = Login. Это позволяет исключить возможность того, что пользователь имеющий один сертификат попытался бы зайти под другим пользователем введя его логин и пароль. Так же я проверяю чтобы в поле OU было значение VPN. Оба значения я добавляю при генерации сертификатов пользователей.

client-connect вызывается скрипт на этапе подключения пользователя. Тут я добавляю адрес пользователя в соответствующую таблицу фаервола, и делаю записи в таблицы log и stat.

client-disconnect вызывается скрипт на этапе отключения пользователя. Удаляю записи из фаервола, делаю записи в таблицы log и stat.

auth-user-pass-verify проверка переданого логина и пароля в БД.

Собственно, ниже сам скрипт

#!/usr/bin/perl

use DBI;
use Digest::MD5 qw(md5_hex);

$dbh=DBI->connect("DBI:Pg:dbname=vpn;host=localhost","ovpn","password");

($script_type,$common_name,$ifconfig_pool_remote_ip,$untrusted_ip) = ($ENV{'script_type'},$ENV{'common_name'},$ENV{'ifconfig_pool_remote_ip'},$ENV{'untrusted_ip'});
if ($script_type eq "client-connect") {
      insert_to_firewall_group();
      logging('start');
}

if ($script_type eq "client-disconnect") {
      delete_from_firewall_group();
      logging('stop');
}

if ($script_type eq "tls-verefy"){
    tls_verefy();
}

if ($script_type eq "auth-user-pass-verify"){
    auth_user_pass_verefy();
}

sub get_group {
     my $req="SELECT groups.id
              FROM groups
                 INNER JOIN users ON (users.groups_id = groups.id)
                     WHERE users.login='$common_name'";
     @row = $dbh->selectrow_array($req);
}

sub  insert_to_firewall_group {
    get_group();
    `/sbin/ipfw table 0 add $untrusted_ip`;
    `/sbin/ipfw table $row[0] add $ifconfig_pool_remote_ip`;

}
sub  delete_from_firewall_group {
    get_group();
    `/sbin/ipfw table 0 delete $untrusted_ip`;
    `/sbin/ipfw table $row[0] delete $ifconfig_pool_remote_ip`;

}

sub tls_verefy {
    ($script_type, $depth, $x509) = @ARGV;
    @X509=split(",",$x509);
    $X509[0] =~s/^OU=//g;$ou = $X509[0]; $X509[1] =~s/^ CN=//g; $cn = $X509[1];
    @ous=('VPN');
    if ($depth == 0) {
        #verefy OU
        foreach(@ous){
            if ($_ eq $ou) {
                   $ou_status = 1;
                   #exit 0;
            }
         }
        #verefy CN
        $req = "SELECT login FROM users WHERE login = '$cn'  AND active = true";
        @row = $dbh->selectrow_array($req);
         if ($row[0] eq $cn) {
               $cn_status = 1;
         }
         if ($ou_status == 1 && $cn_status == 1){
              exit 0;
         }
    exit 1;
  }
}

sub logging {
    ($status) = @_;
    $date = `date '+%Y-%m-%d %H:%M:%S'`;
    chop $date;
    $req = "INSERT INTO log
                  VALUES(DEFAULT,'$date',(SELECT id FROM Users WHERE login='$common_name'),'$untrusted_ip','$ifconfig_pool_remote_ip','$status')";
    $dbh->do($req);
    if ($status eq "start"){
       $st = "INSERT INTO stat
                 VALUES(DEFAULT,'$date','$common_name','$untrusted_ip','$ifconfig_pool_remote_ip')";
        
        $dbh->do($st);
     }
    else {
       $st = "DELETE FROM stat WHERE login='$common_name'";
        $dbh->do($st);
    }

}

sub auth_user_pass_verefy {
    $username = $ENV{'username'};
    $password = $ENV{'password'};
    $common_name = $ENV{'common_name'}; 
    $q_hw = "SELECT hwkey FROM users
                 WHERE login = '$common_name'
                   AND active = true";
   @row = $dbh->selectrow_array($q_hw);
   if ($row[0] == 1 ){
       exit 0;
       } 
    $password = md5_hex($password);
    $req = "SELECT login
                    FROM users
                      WHERE login = '$username'
                          AND password = '$password'
                                 AND active = true";
    @row = $dbh->selectrow_array($req);
    if ($row[0] eq $username) { 
        exit 0;
    }    
    exit 1;
}

Фаервол

В файрволе созданы таблицы где номер таблицы = id группы из таблицы groups

#!/bin/sh
ipfw='/sbin/ipfw -q'
clients_real_ip="table(0)" # сюда заношу реальные адреса клиентов, на всякий случай
admins="table(1)" # таблица для группы admins
rdp="table(2)" # таблица для группы rdp

${ipfw} flush
# -- опускаю стандартные привила --
${ipfw} add allow ip from ${admins} to any  # разрешаем группе админов все
${ipfw} add allow tcp from ${rdp} to any 3389 # разрешаю группе rdp доступ к RDP
${ipfw} add deny  all from ${rdp} to any # запрещаю все остальное группе rdp
# -- другие правила --

осталось на пользовательском устройстве установить p12, скопировать ca.crt и такой конфиг

client
dev tun
proto udp
remote ovpn.domain.ru 1194
resolv-retry infinite
nobind
persist-key
persist-tun
script-security 3
ca "C:\\Program Files\\OpenVPN\\config\\ca.crt"
cryptoapicert "SUBJ:100101"
auth-user-pass 
comp-lzo
log "C:\\Program Files\\OpenVPN\\log\\client.log"
log-append "C:\\Program Files\\OpenVPN\\log\\client.log"
verb 3
route-delay 5 30
tap-sleep 5

Напоследок, вкусняшка для тех кто пользуется дашбордом dashing. Скрипт нужно положить в dashboard/jobs

vpn_stat.rb

require 'pg'
require 'geoip'

$conn = PG.connect( :hostaddr=>'ovpn.domain.local', :user=>'ovpn',:password=>'password',:dbname=>'vpn')

def getVPNstat
  all = Hash.new({ value: 0 })
  result = $conn.exec( "SELECT COALESCE(users.name,stat.login) AS name,stat.realaddress AS ip FROM stat,users WHERE stat.login = users.login")
  result.each do | row |
      user =  row['name']
      user = user[0..8].downcase
      ip = row['ip']
      country = geoip(ip)
      all[user] = {label: user,value: country }
   end
   send_event('vpnstat', { items: all.values })
end

def geoip(ip)
 c = GeoIP.new('/usr/local/www/ruby/dashboard/geoip/GeoIP.dat').country(ip)
 country =  c[4]
 return country
end

SCHEDULER.every '20s' do
    getVPNstat
end

Скачать базу GeoIP:

wget -N http://geolite.maxmind.com/download/geoip/database/GeoLiteCountry/GeoIP.dat.gz

и распаковать ее в dashboard/geoip/.

На дашборд добавить плитку:

    <li data-row="1" data-col="5" data-sizex="1" data-sizey="2">
      <div data-id="vpnstat" data-view="Currency" data-unordered="true" data-title="VPN" style="background-color:green"></div>
    </li>

Выглядет это будет так:



Вот и все. Осталось разве что написать нехитрую веб админку для самых ленивых.
Спасибо за внимание.
Поделиться с друзьями
-->

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


  1. TerAnYu
    18.03.2017 10:45

    Старайтесь избегать использования абсолютных путей.


    Вместо


    ca "C:\\Program Files\\OpenVPN\\config\\ca.crt"

    Можно записть его прямо в файл конфигурации:


    <ca>
    -----BEGIN CERTIFICATE-----
    текст самого сертификата
    -----END CERTIFICATE-----
    </ca>

    Есть вероятность что в вашем случае такое не заработает.


    Вот это тоже нет необходимости указывать.


    log "C:\\Program Files\\OpenVPN\\log\\client.log"
    log-append "C:\\Program Files\\OpenVPN\\log\\client.log"

    Можно это указать так:


    log "..\\log\\client.log"
    log-append "..\\log\\client.log"

    Хотя имеет ли смысл указывать логи, ведь они сами называются согласно имени файла конфигруации.
    Файли имеет имя client_office_tcp.ovpn, то файл лога будет называться client_office_tcp.log в папке log.


    И да, "web-админка" уже есть: https://github.com/furlongm/openvpn-monitor
    По крайней мере она меня устраивает.


    1. TerAnYu
      18.03.2017 10:49

      Либо логи указать так:


      log "../log/client.log"
      log-append "../log/client.log"


      1. borisovEvg
        18.03.2017 12:26

        Можно это указать так:
        log "..\\log\\client.log"
        log-append "..\\log\\client.log"

        В официальном гайде предлагают использовать абсолютные пути, но если это так работает тоже, ок! пусть будет так.
        P.S. В новых версиях похоже эта директива вообще игнорируется, предполагаю что значения берутся из реестра, и настраиваются через GUI
        И да, «web-админка» уже есть: https://github.com/furlongm/openvpn-monitor
        По крайней мере она меня устраивает.

        Судя по описанию это монитор. Но спасибо за наводку. Говоря про админку, я имел в виду написания приложения через которое можно было бы управлять пользователями(добавлять, отключать и тп), правилами, группами и т.д.


        1. TerAnYu
          18.03.2017 12:43

          Просто использование путей уменьшает универсальность конфига, его невозможно (наверное) использовать в других ОС системах без изменений.
          Официальные гайды, похоже, мало когда актуализируют.


          Ну да, это монитор, поэтому и написал в кавычках.
          Всё же использование ldap как-то более универсально, чем использование велосипедов, но это дело вкуса.


  1. vviz
    19.03.2017 16:39

    Напоминает изобретение велосипеда, ИМХО.
    Привязываем OpenVPN к базе AD через openvpn-plugin-auth-pam.so и живем спокойно.
    Не замучаетесь на личных уст-вах пользователей ставить сертификаты? Особенно для планктона.
    Правильнее, ИМХО, один сертификат на всех + пароль/логин при подключении.
    Какой смысл создавать под каждого пользователя правило в пакетном фильтре?
    Правильнее, сертификаты и ключи пользователей прописывать в файле конфига пользователя, как уже было предложено ранее.
    Используйте tls-auth для предотвращения тупого долбления на сервер.
    Затем, все пользовательские данные — инсталлятор клиента, конфиг, RDP файлы сворачиваются в самораспаковывающийся архив со скриптом, переписывающим все в нужные каталоги на компе пользователя и выкладывается на FTP/HTTP сервер.


    1. borisovEvg
      19.03.2017 23:52

      Напоминает изобретение велосипеда, ИМХО.
      Привязываем OpenVPN к базе AD через openvpn-plugin-auth-pam.so и живем спокойно.

      Если кто нибудь напишет модуль openvpn-plugin-auth-sql.so это перестанет быть велосипедом?!
      Вы тем кто скрещивает exim с psql или mysql тоже советуется не выдумывать велосипед и прикручивать его к ldap? Модуль то есть такой. И как быть тогда тем, у кого нет AD, поднимать его специально для хранения учеток?

      Но посыл этой статьи не в том, где по вашему мнению должны хранится учетки, а в том что Openvpn(и не только он), зачастую позволяет отойти от шаблонов, и реализовать то, чего требую обстоятельства…

      Правильнее, ИМХО, один сертификат на всех + пароль/логин при подключении.

      Все от задач. Где-то можно вообще в общий доступ все выложить. где то нужны ключи зашитые в eToken
      Какой смысл создавать под каждого пользователя правило в пакетном фильтре?

      Тот же ответ. Разным группам сотрудников нужны разные уровни доступа. Кому-то к одному сайту, кому-то к другому сайту, почте, сетевым ресурсам и тп.


      1. vviz
        20.03.2017 00:15

        Совершенно верно, OpenVPN и иже сним позволяют решать множество задач. Если Вы ставили своей задачей составить хитроумное решения для тренировки интеллекта, то Вы сделали это прекрасно. Если же Вы ставили целью решить определенную задачу для продакшена, то извините, вычурно и избыточно, ИМХО.
        И да, поднять контроллер AD стоит даже для такой задачи, виртуализация возможна сейчас практически на любых плаформах и ресурсах. Кроме всего прочего, можно использовать для авторизиции для Squid, Socks, Wi-Fi и т.д.


        1. selivanov_pavel
          21.03.2017 16:02

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

          То есть всегда стоит добавить например в linux-only инфраструктуру ещё один тип ОС, заводить для него мониторинг, установку обновлений безопасности, обеспечение отказоустойчивости, интеграцию с DNS? И ещё заплатить за это денег


  1. ALexhha
    20.03.2017 14:05
    +2

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

    а деньги за лицензии кто будет платить? АД как бы платный. Тогда уж смотреть в сторону OpenLDAP.

    Если Вы ставили своей задачей составить хитроумное решения для тренировки интеллекта, то Вы сделали это прекрасно

    вполне реальная задача, имхо. У меня было нечто подобное на AWS. Когда мы делали единую точку входа на нескольких аккаунтах, на каждом из которых использовались по 2-3 региона. И вот там надо было на основе имени пользователя давать/не давать права доступа в определенный аккаунт/регион/подсеть/vpc.