developer.co.ua

Holy Copypasters
17.10.2007
Евгений Загородний

Шлюз с авторизацией и динамическим распределением канала на базе pf+altq и authpf 0.14

Введение

Итак, имеется локальная сеть и канал провайдера. Задача — обеспечить определенным пользователям сети доступ к каналу провайдера. При этом ширина канала ограничена, поэтому его необходимо распределить между пользователями «справедливо», то бишь поровну, но при этом по возможности максимально использовать его ширину.
Решение поставить для каждого пользователя ограничение ширина_канала/количество_пользователей по очевидным причинам неприемлемо — при такой политике полная ширина канала будет задействована очень редко, так как глупо ожидать, что все пользователи будут пользоваться им одновременно и полностью исчерпывать свою «долю».
Варианты вроде собрать статистику и поставить ограничение, исходя из среднего количества активных пользователей тоже восторга не вызывают, по тем же причинам.
Вывод — фиксированное ограничение скорости тут не пройдет, надо копать глубже.

Выбор платформы

Из средств контроля траффика мне сразу попались на глаза штатный FreeBSD-шный шейпер dummynet и механизм альтернативного построения очередей altq, поддерживаемый штатным OpenBSD-шным фаерволлом pf.
Опыта работы ни с первым, ни со вторым у меня не было, поэтому мой выбор в некоторой мере субъективен. Я остановился на pf+altq, потому что мне в любом случае нужно было обеспечить NAT, а pf в этом более удобен, чем, скажем, ipfw.
Кроме того, высказывалось мнение что altq использует более «умные» и гибкие мехенизмы, нежели dummynet. Да и идея об иерархической структуре очередей с возможностью для потомка позаимствовать (borrow) траффик у родительской очереди в контексте поставленной задачи выглядит симпатично.
(На этом обсуждение темы “altq vs dummynet” я закончу — она и так едва ли не холиварная ;) )
И, наконец, как оказалось, под pf существует авторизационный шелл authpf, но об этом позже.
Итак, дальше работаем с FreeBSD 6.2 + pf.

Теория

Теперь несколько слов о том, как осуществляется контроль входящего траффика (а для большинства пользователей именно входящщий траффик является основным). Самый лаконичный и строгий ответ, как ни странно, — никак!
Действительно, мы вольны как угодно шейпить (читай: дропать) пришедшие пакеты. Но они ведь уже пришли! А следовательно — заняли часть используемого канала.
Как же быть? Тут на помощь приходит реализованные в протоколе TCP средства контроля скорости. Грубо говоря, если от получателя не пришли подтверждения о получении определенного количества пакетов — передатчик начинает отсылать их медленнее, подстраивая скорость под технические возможности линии связи. Так, через некоторое время (не мгновенно!) канал будет занят настолько, насколько мы обрезали входящий траффик.
Разумеется, такой механизм не сработает, если канал был намеренно перегружен недоброжелателем — он не станет отсылать пакеты медленнее, увидев, что не все они успевают обрабатываться. Но это уже другая история.
Подробнее об этом можно почитать здесь.

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

Особенности pf

Зараннее отмечу пару фактов о фаерволле pf, которые обязательно надо иметь в виду.


Трудно поверить, но стройность этих теоретических рассуждений не подвела и на практике.

Начинаем: авторизация

Здесь не будет рассматриваться настройка маршрутизации, так как она рассмотрена во множестве других материалов. Итак, шлюз заработал как роутер, теперь займемся авторизацией.
Первое, что выдал Google по этому поводу — это authpf, авторизационный шелл для шлюза, использующего pf.
Работает он следующим образом. Он назначается в качестве login shell для пользователей, которые должны авторизоваться, и следовательно, запускается каждый раз, когда пользователь открывает ssh-сессию. Запускаясь, он динамически изменяет правила фаерволла pf, используя механизм anchor-ов и таблиц. Эти правила остаются в силе до тех пор, пока соответствующая ssh-сессия не прервется. Дешево и сердито.

Средства, предоставляемые authpf, довольно гибки, — скажем, можно определить динамические правила, индивидуальные для каждого пользователя. Однако... для намеченной цели (задание разделения траффика между пользователями в зависимости от количества пользователей, авторизовавшихся в данный момент) требуется каждый раз, когда пользователь зашел/вышел менять определения очередей в правилах pf, вплоть до добавления новых очередей и удаления старых. К сожалению, механизм anchor-ов делать этого не позволяет, поэтому придется перезагружать полный список правил.

Динамическое построение очередей: генерируем правила

Оговорюсь (возможно, запоздало), что не буду дублировать официальную документацию. За ней обращайтесь к первоисточникам, а точнее — к man-ам по pf.conf(5) и authpf(8).

Определимся, что должно происходить при изменении количества залогиненых пользователей.
Во-первых, должен изменяться раздел таблиц правил pf. Он должна выглядеть примерно так:

Почему именно таблицы, а не простые макроопределения? Дело в том, что authpf позволяет одновременную авторизацию с нескольких IP-адресов, и хочется, чтобы эта его возможность поддерживалась, причем канал при этом должен делиться не между IP-шниками, а между пользователями.

Во-вторых, измениться должен раздел очередей. Для ясности отмечу, что в pf.conf прописаны следующие определения (они меняться не будут):

Здесь активирован механизм altq на внешнем и внутреннем интерфейсах, и на каждом из них определены очереди для связи с провайдером, и default — необходимая очередь, в которую попадают пакеты, не попавшие ни в какую другую. (Определения, не имеющие отношения к решаемой задаче, здесь и ниже будут опускаться, чтобы не захламлять текст).

Динамическая часть раздела очередей должна выглядеть примерно так (продолжая предыдущий пример):

Причем эти 50% для троих залогиненых пользователей превратятся в 33%, для четверых — в 25%, и так далее. Собственно, ради этого все и затевалось :)

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


Как можно было догадаться, все идет к написанию шелл-скрипта, который будет брать за основу существующий pf.conf и вставлять в нужные места динамически генерируемые части. Чтобы облегчить ему (скрипту) работу, в pf.conf были добавлены специальные комментарии-флажки: #%T, #%Q, #%F — в тех местах, куда следует вставлять указанные три сгенерированых куска.

Такой скрипт (/etc/authpf/requeue) и был написан. Он, основываясь на выводе команды ps, (благо, разработчики authpf позаботились о том, чтобы вывод ps был легко обрабатываемым, об этом — дальше), с помощью grep-ов и sed-ов создает нужные списки, читает pf.conf и создает «новый» список правил — со вставленными новыми кусками, после чего загружает его с помощью pfctl.

Отслеживание входа-выхода: ковыряем authpf

Теперь, когда скрипт готов, надо определиться, когда и как его запускать. Cron тут не поможет — если пользоваться только им, то пользователь, авторизовавшись вынужден будет ждать в среднем полминуты перед тем, как под него создастся очередь. Естественнее всего запускать скрипт при каждой успешной авторизации в authpf и при каджом завершении сессии authpf. Увы, в документации по authpf не было найдено ничего по запуску пользовательских программ при этих событиях. Значит, придется добавить нужную функциональность самостоятельно.
Нужные места в исходнике authpf (/usr/src/contrib/pf/authpf/authpf.c) нашлись без труда, и код в них дополнен.
Определение глобальной переменной для формирования строки, передающейся командному интерпретатору:
char *prompt;



Запуск скрипта при успешной авторизации:
printf(, luser);
printf("You are authenticated from host ""rn", ipsrc);
setproctitle(, luser, ipsrc);

/* мой код */
asprintf(&prompt, , PATH_RUN, luser, ipsrc, (long)getpid());
system(prompt);
free(prompt);
/* конец */

print_message(PATH_MESSAGE);


Запуск скрипта при закрытии сессии:
if (active) {
  change_filter(0, luser, ipsrc);
  change_table(0, luser, ipsrc);
  authpf_kill_states();
  remove_stale_rulesets();
  
  /* мой код */
  setproctitle("dying");
  asprintf(&prompt, , PATH_STOP, luser, ipsrc, (long)getpid());
  system(prompt);
  free(prompt);
  /* конец */

}


И в файле pathnames.h определения путей к скриптам:
#define PATH_RUN  "/etc/authpf/authpf.run"
#define PATH_STOP "/etc/authpf/authpf.stop"


После этого authpf был пересобран и переустановлен:
# cd /usr/scr/usr.sbin/authpf
# make
# make install


Что это дало? Теперь после успешной авторизации будет запускаться /etc/authpf/authpf.run с тремя параметрами: имя авторизовавшегося пользователя, IP-адрес, с которого прошла авторизация, и pid шелла, из которого был запущен скрипт. После закрытия сессии будет вызываться /etc/authpf/authpf.stop с теми же параметрами.

Обратите внимание на вызовы setproctitle() — они изменяют название процесса, которое выводится командой ps.
Первый вызов присутствовал изначально — специально дла того, чтобы можно было в любой момент просмотреть список авторизовавшихся пользователей простым «ps | grep authpf». Принципиально, чтобы наш скрипт выполнялся после вызова setproctitle() — так как он использует именно вывод ps.
Второй вызов был добавлен мной. Зачем? Дело в том, что во время выполнения «завершающего» скрипта процесс authpf, который готовится «умереть», все еще висит в выводе ps. Но в то же время он там не нужен — мы ведь хотим, чтобы очередь выходящего пользователя удалилась. Это достигается изменением заголовка процесса authpf на “dieing” — он все равно будет в выводе ps, но отбросится grep-ом внутри скрипта.

Вообще говоря, только что была сделана сомнительная вещь — добавление не очень культурного кода в часть проекта OpenBSD, один из принципов которого — постоянный аудит кода и исправление мельчайших, даже незначительных ошибок.
Что же некультурного в добавленном? Во-первых, вызов asprintf() может завершиться неудачей из-за нехватки памяти, и эту ситуацию следует обрабатывать. Во-вторых, вызов system() может повлечь появление на стандартном выводе ненужной информации (например, сообщения об ошибке), которая станет видна авторизовавшемуся пользователю, а это ему ни к чему. Чтобы избежать этого, придется внимательно следить, чтобы скрипты были на месте, права доступа позволяли их запускать, и чтобы они (скрипты) не производили нежелательного вывода.
Откровенно говоря, эти «некультурности» были оставлены в таком виде в значительной степени из-за моей лени. Что ж, на моей совести осталось пятно — но давайте двигаться дальше.

Права доступа: setuid-ные страсти

Возвращаясь к скрипту /etc/authpf/requeue вспомним, что он изменяет конфигурацию pf, а следовательно — должен выполняться с правами root. Setuid для шелл-скриптов не работает, поэтому напишем короткую программу на C:
int main (int argc, char argv[])
{
  system(/etc/authpf/requeue);
  return 0;
}


Скомпилируем ее в /etc/authpf/requeue.suid, и установим для нее владельца и права доступа:


Теперь кто бы ни запустил requeue.suid — она будет выполняться с правами root. В то же время правами на ее запуск обладает только группа authpf. А права группы authpf можно получить только из setgid-ного authpf, который стоит в качестве login shell у наших пользователей.
Таким образом обеспечивается возможность запуска потенциально опасной программы только в тех случаях, когда это действительно требуется.

Последние штрихи: связываем все воедино

Теперь осталось разместить скрипты, выполняющиеся при входе и выходе пользователя из системы там, где их ожидает найти authpf.

/etc/authpf/authpf.run:


/etc/authpf/authpf.stop:


Небольшое отступление. В процессе отладки работы своего творения я несколько раз сталкивался со «сверхестественными» непонятностями. Вся эта «сверхестественность», как оказывалось потом, была результатом банальной невнимательности.
Однако одно из «чудес» пока остается для меня чудом. А именно — если запускать requeue из authpf.stop без “&" в конце — скрипт виснет на операциях rm и mv. C “&" — работает весьма быстро и вполне корректно. Буду благодарен, если кто-нибудь просветит меня, почему так происходит.

Заключение

Итак, несмотря на внешнюю аляповатость, все «это» в конце-концов заработало.
Полагаю, излишне предупреждать о том, что получившийся инструмент особо жесткому тестированию не подвергался, возможно, содержит ошибки, и не претендует на безукоризненность.

... В этом месте невольно вспоминается фраза:
Unix — это не свалка костылей и подпорок.
Это стройная, логичная система костылей и подпорок.


Приложения



/etc/authpf/requeue

Тот самй скрипт, который запускается каждый раз, когда очередной пользователь заходит/выходит. Помимо того, о чем говорилось в статье, в нем реализовано отделение UA-IX от не-UA-IX траффика (дублируя политику провайдера).


related files

Список файлов, созданных в процессе реализации системы.

права доступа владелец путь описание
rwxr--r-- root:wheel /etc/authpf/requeue Тот самый скрипт
rws--x--- root:authpf /etc/authpf/requeue.suid C-программа, запускающая requeue с правами root
rwxr-x--- root:authpf /etc/authpf/authpf.run Скрипт, запускаемый authpf при входе пользователя
rwxr-x--- root:authpf /etc/authpf/authpf.stop Скрипт, запускаемый authpf при выходе пользователя
rw-rw---- root:authpf /var/log/authpf.inout Лог, фиксирующий вход/выход пользователей
rw-rw---- root:authpf /var/log/authpf.requeue Лог, фиксирующий подгрузку сгенерированных правил в pf
rw-rw---- root:authpf /var/log/authpf.requeue.bad_rules Сюда для дебага сохраняются сгенерированные правила, которые pf отказался «кушать»
rw------- root:wheel /tmp/authpf.requeue.rules Сгенерированные правила
rw------- root:wheel /tmp/authpf.requeue.rules.old Предыдущие правила
rw------- root:wheel /tmp/authpf.requeue.tables Сгенерированная секция таблиц
rw------- root:wheel /tmp/authpf.requeue.queues Сгенерированная секция очередей
rw------- root:wheel /tmp/authpf.requeue.fiters Сгенерированная секция фильтров
rw------- root:wheel /tmp/authpf.requeue.users_table Список активных пользователей и адресов
rw------- root:wheel /tmp/authpf.requeue.users_list Список активных пользователей

1 2 3 4 5

Последние комментарии:

ОЛОЛО мфынф
Статья полнейший бред

Евгений Загородний
Крон не устраивал, потому что он запускает что-либо не чаще, чем раз в минуту. А заставлять пользователя ждать в среднем по полминуты, прежде чем его запрос на авторизацию обработается, неприемлемо.

На всякий случай: описанной здесь методикой я уже не пользуюсь, и развивать ее не намерен. Так что статью лучше рассматривать, как одну историю про то, как бывает, если никем-не-реализовано-но-очень-хочется :)

Alexander
Зачем на Си писать тулзу с setuid?
Чем вам не нравится крон?

Евгений Загородний
Да, действительно, — нетиражируемо, это серьезный недостаток. Вопрос решался «здесь и сейчас», о возможной необходимости проделать аналогичное на других узлах не задумывался :)

Заведение нового пользователя требует ровно двух действий: обычный useradd + запись в authpf.allow.

Функциональность htb, судя по (увы, проглянутой по диагонали) документации, практически не отличается от таковой у altq. По крайней мере, как с помощью простого конфигурирования, без «костылей», добиться справедливого разделения пропускной способности без фиксированных ограничений скорости — непонятно.

Специфика рассматриваемой сети заключается в том, что она маленькая (8–16 клиентов), соответственно, скорость связи с внешним миром – тоже, и не использовать ее в полной мере — непозволительно.
Если можно, — подскажите, где узнать про стандартизованные решения, учитывая эту специфику.
(В сторону PPPoE и Radius непременно гляну.)

набор костылей, нетиражируемо. Andrew Degtiariov
набор костылей, нетиражируемо.

– плюс заведение нового пользователя дофига действий требует
– htb в линуксе проще гораздо
А авторизацию лучше делать другими путями (PPPoE например)
Лучше использовать стандартизированые решения, чтобы был маневр при смене платформы
Понятно, что это не этот случай, но при росте количества клиентов, можно поставить какую-то коммерческую платформу и если при проектировании на это закладывались (использование стандартных вещей PPPoE + Radius) то все это происходит более-менее незаметно для клиентов

ну и на клиентских интерфейсах ограничивать максимальную скорость, а на внешнем роутере (когда все в одном флаконе это конечно сложно сделать) включить WFQ для нескольких конечных групп трафика.
То есть, шейпинг на клиентских интерфейсах, приоритезация – на аплинке. Если сеть большая, то во всей сети (именно поэтому должно быть конечное число классов трафика)

Обсудить (комментариев: 5)