OpenVPN комбинируем с БД.

Копия статьи с хабра. На этот раз на тему работы с OpenVPN. Что довольно неплохо ложится на мои предыдущие эксперименты с этой системой. Поскольку хороших и оригинальных материалов на русском по теме не так уж и много, не хочется его потерять в цифровом шуме. Тем кто интересуется или работает с OpenVPN лучше читать оригинальную статью на хабре:

Захотелось собрать 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>

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

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