FreeRADIUS as DHCP server

Давно хотел написать, да руки не доходили.

FreeRADIUS, начиная с версии 2.0.4 поддерживает работу в качестве DHCP-сервера. Идея, в общем, логичная. Протоколы имеют много общего (пары атрибут-значение) и предназначены для схожих задач (выдать параметры конфигурации).



Меня эта тема достаточно серьёзно интересует, т.к. решение с OMAPI хоть и "красивое", но, всё-таки, факт отсутствия жёсткой связи с базой биллинга, невозможность массовых операций и вообще достаточно жёсткие рамки ISC DHCP сервера заставляли искать альтернативу. А тут как раз подвернулся проектик: выдача IP-адресов STB'шкам. STB - Set Top Box - это такой embedded linux, подключается к ethernet, получает свой IP по DHCP и показывает мультикастовое IPTV на телевизор. Управляется с пульта.

Идея была такая: мы не имеем пока доступа к middleware, где крутится IPTV, которое мы продаём, поэтому отключать за неуплату абонента можно только воздействуя либо на его порт (что возможно, но не совсем просто, т.к. у нас нет жёсткой привязки к портам) либо на сам STB. Придумалась схема: к аккаунту привязывается MAC-адрес устройства (который написан прямо у него на брюхе) и на каждый DHCPREQUEST мы анализируем состояние аккаунта и либо выдаём адрес, если всё хорошо, либо отвечаем NACK'ом и STB пишет "обратитесь к оператору", а телевидение, наоборот, не показывает.

Под такое дело я решил попробовать мой любимый FreeRADIUS.

Во-первых, в секции listen {} нужно прописать новые порты типа dhcp (что логично). Нужно подключить новый словарь dictionary.dhcp, который описывает DHCP-атрибуты.
Обработка каждого типа запроса выполняется в отдельных секциях. Можно сделать ещё одну catch-all секцию без спецификации типа.

В секциях всё описавается так же, как и, например, в authorize {} - т.е. модули и unlang. У модулей дёргается только post_auth хук, поэтому, к сожалению, не все rlm можно использовать. Например, я очень хотел применить rlm_sql, но в post_auth секции он не умеет отдавать атрибутов, и для меня оказался бесполезным. Для логики оставались два модуля rlm_perl и rlm_python. (rlm_exec отпал сразу, т.к. "уже сейчас понятно, что всё будет глючить и тормозить" ©)

К сожалению, у rlm_python отсутсвовал post_auth хук (что решилось несложным патчем), но он ещё расстроил меня тем, что freeradius не захотел выходить по Ctrl+C в режиме отладки при используемом питоне. Сразу представились глюки при многопоточном режиме работы, непослушность сигналам и т.п. Отказать.
Остаётся перл, хоть я и не очень его люблю.

Был написан достаточно простой скрипт, который коннектится к базе, дёргает там функцию с двумя аргументами (chaddr, giaddr) и получает в ответ всё, что должен знать STB: ip, mask, gw и длительность лизы. Или ничего, если MAC базе не понравился. Ну и отладочное сообщение для инженеров, разумеется, чтоб не скучали.

Кстати, огромный респект Perl DBI за connect_cached()! Это просто гениальная вещь, на мой взгляд. За одно это можно простить перлу весь его геморрой :)

Выглядит это (за вычетом информации, которую я не могу разглашать) так:

use DBI;
use vars qw(%RAD_REQUEST %RAD_REPLY %RAD_CHECK);

use constant    RLM_MODULE_REJECT=>    0;#  /* immediately reject the request */
use constant    RLM_MODULE_FAIL=>      1;#  /* module failed, don't reply */
use constant    RLM_MODULE_OK=>        2;#  /* the module is OK, continue */
use constant    RLM_MODULE_HANDLED=>   3;#  /* the module handled the request, so stop. */
use constant    RLM_MODULE_INVALID=>   4;#  /* the module considers the request invalid. */
use constant    RLM_MODULE_USERLOCK=>  5;#  /* reject the request (user is locked out) */
use constant    RLM_MODULE_NOTFOUND=>  6;#  /* user not found */
use constant    RLM_MODULE_NOOP=>      7;#  /* module succeeded without doing anything */
use constant    RLM_MODULE_UPDATED=>   8;#  /* OK (pairs modified) */
use constant    RLM_MODULE_NUMCODES=>  9;#  /* How many return codes there are */

use constant    L_DBG=>         1;
use constant    L_AUTH=>        2;
use constant    L_INFO=>        3;
use constant    L_ERR=>         4;
use constant    L_PROXY=>       5;
use constant    L_CONS=>        128;

my $dbh = "";
sub post_auth {
        my $ciaddr = $RAD_REQUEST{'DHCP-Client-IP-Address'};
        my $giaddr = $RAD_REQUEST{'DHCP-Gateway-IP-Address'};
        my $chaddr = $RAD_REQUEST{'DHCP-Client-Hardware-Address'};
        my $xid = $RAD_REQUEST{'DHCP-Transaction-Id'};
        my $msgtype = $RAD_REQUEST{'DHCP-Message-Type'};
        &dbConnect();
        unless ( $dbh ) {
                &radiusd::radlog(L_ERR, "$xid: No DB connection, failing...");
                $RAD_REQUEST{'Tmp-String-0'} = 'No DB connection.';
                return RLM_MODULE_NOOP;
        };
        
        unless ( $sth = $dbh->prepare("select host(ip), host(mask), host(gw), lease, message from dhcp_stb(?,?);") ) { 
                &radiusd::radlog(L_ERR, "$xid: DB statement preparation failed: " . $dbh->errstr);
                $RAD_REQUEST{'Tmp-String-0'} = 'SQL statement preparation has been failed.';
                return RLM_MODULE_NOOP;
        };
        
        unless ( $sth->execute($chaddr,$giaddr) ) {
                &radiusd::radlog(L_ERR, "$xid: DB statement execution failed: " . $dbh->errstr);
                $RAD_REQUEST{'Tmp-String-0'} = 'SQL statement execution has been failed.';
                return RLM_MODULE_NOOP;
        };
        $sth->bind_columns(\$yiaddr, \$mask, \$gw, \$lease, \$message);
        $ary_ref  = $sth->fetchall_arrayref;

        if ( $yiaddr and $mask and $gw ) {
                $RAD_REPLY{'DHCP-Your-IP-Address'} = $yiaddr;
                $RAD_REPLY{'DHCP-Subnet-Mask'} = $mask;
                $RAD_REPLY{'DHCP-Router-Address'} = $gw;
                $RAD_REPLY{'DHCP-IP-Address-Lease-Time'} = $lease;
                if ( $message ) {
                        $RAD_REQUEST{'Tmp-String-0'} = $message;
                } else
                {
                        $RAD_REQUEST{'Tmp-String-0'} = 'OK';
                };
                return RLM_MODULE_OK;
        };
        if ( $message ) {
                $RAD_REQUEST{'Tmp-String-0'} = $message;
        } else
        {
                $RAD_REQUEST{'Tmp-String-0'} = 'no IP from DB.';
        }
        return RLM_MODULE_NOTFOUND;
     
};

sub dbConnect {
        $dbh = DBI->connect_cached("DBI:Pg:dbname=$DBName;host=$DBHost", $DBUsername, $DBPassword) or 
                &radiusd::radlog(L_ERR, "DB connection failed: " . DBI->errstr);
};


Внутри конфига FreeRADIUS это обрабатывается следующим образом (опять же, некоторые критичные вещи вырезаны или изменены):
dhcp DHCP-Discover {
        # Set server ID field (it MUST be in the reply packet)
        update reply {
                        DHCP-DHCP-Server-Identifier = "%{Packet-Dst-IP-Address}"
        }
        #Log the request
        linelog
        #Process the request
        perl
        #Update reponse if request succeeded
        if (ok) {
                # Address was found
                update reply {
                        DHCP-Message-Type = DHCP-Offer
                        DHCP-NTP-Servers = 1.1.1.1
                }
        }
        else {
                #In any other case - don't send anything:
                update reply {
                        DHCP-Message-Type = 0
                }
        }

        # Log the response
        linelog
        ok
}

dhcp DHCP-Request {
        # Set server ID field (it MUST be in the reply packet)
        update reply {
                        DHCP-DHCP-Server-Identifier = "%{Packet-Dst-IP-Address}"
        }
        #Log the request
        linelog
        #Process the request
        perl
        #Update reponse with all required information on success
        if (ok) {
                # Address was found
                update reply {
                        DHCP-Message-Type = DHCP-ACK
                        DHCP-NTP-Servers = 1.1.1.1
                }
        }
        elsif (notfound) {
                # Address was not found, send NAK response
                update reply {
                        DHCP-Message-Type = DHCP-NAK
                }
        }
        else {
                #In any other case - don't send anything:
                update reply {
                        DHCP-Message-Type = 0 
                }
        }
        #Log the response
        linelog
        ok
}
dhcp DHCP-Release {
        handled
}
dhcp DHCP-Inform {
        handled
}
dhcp {
        handled
}


Вот и почти всё. Здесь, к сожалению, не полное соответствие RFC2131 (в частности, не анализируест список атрибутов, которые хочет видеть клиент), но нам это и не нужно.

Логи для дежурных пишутся очень информативные, благодаря огромному количеству функций rlm_linelog:

linelog {
 filename = syslog
 format = ""
 reference = "%{%{reply:DHCP-Message-Type}:-%{request:DHCP-Message-Type}}"
 DHCP-Discover = "%{DHCP-Transaction-Id} DISCOVER: [%{DHCP-Client-Hardware-Address}] via (%{DHCP-Gateway-IP-Address}) %{DHCP-Hostname}"
 DHCP-Offer = "%{DHCP-Transaction-Id} OFFER: %{reply:DHCP-Your-IP-Address} to [%{DHCP-Client-Hardware-Address}] ..."
 DHCP-Request = "%{DHCP-Transaction-Id} REQUEST: [%{DHCP-Client-Hardware-Address}] via (%{DHCP-Gateway-IP-Address}) ..."
 DHCP-Ack = "%{DHCP-Transaction-Id} ACK: %{reply:DHCP-Your-IP-Address} to [%{DHCP-Client-Hardware-Address}] ..."
 DHCP-NAK = "%{DHCP-Transaction-Id} NAK: [%{DHCP-Client-Hardware-Address}] for %{request:DHCP-Client-IP-Address}; ..."
 0 = "%{DHCP-Transaction-Id} %{request:DHCP-Message-Type} DROPPED: ..."
}


Очень удобный модуль.

Каждый трид freeradius запускает свою копию rlm_perl и устанавливает свой коннекшн с базой. Чтобы количество соединений не вырастало за разумные рамки, я ограничил максимальное количество тридов и, в спокойном режиме, когда у нас не более одного пакета за один раз (а обычно так и есть), у меня работает только один трид. Ну и перезапускаю я триды раз в 64 запроса, на всякий случай.

thread pool {
        start_servers = 1
        max_servers = 8
        min_spare_servers = 0
        max_spare_servers = 2
        max_requests_per_server = 64
}


Со стороны базы у нас уже была "схема сети" с VLAN'ами, сетями и диапазонами адресов под динамические пулы (калька конфига ISC DHCPd), поэтому особо мудрствовать не пришлось. Была написана пара функций на PlPgSQL и PlPython, которые и занимаются менеджементом адресов и лиз.

Вот так. Работает, память (на первый взгляд) не жрёт, адреса отдаёт. Как только у абонента баланс проваливается ниже 0, на следующем DHCPREQUEST он получает NACK и перестаёт показывать картинку. В принципе, разбег в несколько часов (предполагаемая длительность lease в боевом режиме) между изменением баланса и отключением TV не критичен. Мы не жадные :)

Источник статьи