Cisco Downloadable ACL (ACSACL) & FreeRADIUS

Предполагается, что читатель/внедренец ориентируется в принципах работы протокола RADIUS и конфигурирования FreeRADIUS-server, и имеет опыт эксплуатации одного из девайсов линейки PIX/ASA/FTD/ISR (либо имеет доступ к телу для надругательства). А начнем, как это принято в приличном обществе, с терминологии.

Когда речь заходит о Remote Access VPN на базе решений Cisco, обязательно всплывает вопрос ограничения доступа удаленных пользователей и централизованного управления оным. Следует отдать должное спецам компании - начиная с VPN3000, PIX, IOS с классическим EzVPN, и заканчивая ASA/FTD с AnyConnect, несмотря на изменения в протоколах доступа и усложнение функционала, протоколы авторизации остались прежними (естественно, обрастая дополнительными возможностями).

В частности, речь идет о Downloadable ACL - наборе ACE (Access Control Entry или Access Control Rule), передаваемом на PIX/ASA RADIUS-сервером вместе с Access-Accept пакетом в составе Cisco RADIUS VSA. Формат для EzVPN/AnyConnect следующий:

Cisco-AVPair += "ip:inacl#<number>=ACE"

где number=0..999999999, а ACE - правило в формате extended ACL PIX/IOS. Возможны еще вариации на тему Clientless подключений - там формат ACE немного иной, но принцип не меняется.

IOS использует wildcard формат маски, характерные команды и принципы конфигурации и отладки. Но тоже поддерживает обсуждаемый в статье функционал (хотя и не для всех доступных на нем типов RA VPN). А PIX/ASA, в свою очередь, поддерживают ACL в формате IOS. Но для экономии места и нервов, оба нюанса в статье опущены.

Таким образом, клиенту передается массив данных, вида:

ip:inacl#1=permit tcp 10.0.0.0 255.0.0.0 172.16.0.0 255.240.0.0 eq 3389
ip:inacl#10=permit tcp 10.0.0.0 255.0.0.0 10.0.0.0 255.0.0.0 eq 3389
ip:inacl#11=permit udp 10.0.0.0 255.0.0.0 any eq 53
...
ip:inacl#99=deny ip any any

в составе атрибута Cisco-AVPair (это первый вариант загрузки). Последнее deny тут исключительно для мебели (для иллюстрации последнего ACE) и применяется неявно по-умолчанию. Все это выглядит именно так, пока RADIUS пакет с ACL не превышает 4KB - наследие RFC2865 и принципа обратной совместимости, согласно которому старые клиенты, не знающие об RFC6613/7930 не должны иметь проблем с новыми реализациями сервера (впрочем и не будут иметь, пока RFC7930 не выйдет из статуса Experimental).

При превышении лимита в 4KB начинается... для начала - начинается путанница в терминологии. Поскольку для Challenge-Response механизма передачи ACL клиенту (это второй вариант), отдельного названия не предусмотрено. Для поиска по теме лучше использовать термин "ACSACL", поскольку по запросу "Downloadable ACL", "ip:inacl" и "Cisco-AVPair ACL" вываливается в основном инфа по первому варианту. В контексте PIX/ASA, сама Cisco оперирует терминами "Cisco-AV-Pair ACL" и "Downloadable ACL" ("DACL") для первого и второго вариантов соответственно. Но только пока речь идет об обоих сразу - если в документации речь идет только о первом, его не заморачиваясь называют "Downloadable ACL". Я буду использовать PIX-овую терминологию в сокращении - cavp-acl и dacl соответственно.
Подробное описание процесса имеется в Cisco ASA Series CLI Configuration Guide, до версии 9.1 включительно - в поздних версиях структура документации изменилась с уклоном в "понижение порога вхождения", а подробности о реализации dACL из нее испарились.

Итак, способ передачи over-4K ACL основан на штатном для RADIUS механизме Access-Challenge.
Обычный клиент-серверный обмен по RADIUS протоколу инициируется клиентским запросом Access-Request и завершается ответом сервера Access-Accept; или Access-Reject, если пользователь не прошел аутентификацию/авторизацию.
Если серверу потребовался дополнительный обмен с клиентом, вместо ответа Access-Accept отправляется отклик Access-Challenge с установленным атрибутом State RFC2865 - маркером этапа обмена.
Клиент обрабатывает данные от сервера и возвращает необходимые данные в составе повторного запроса Access-Request. Клиент так-же копирует атрибут State из отклика Access-Challenge сервера для того, чтобы сервер мог определить - в ответ на какой из Access-Challenge пришел данный Access-Request.
Сервер может повторить итерацию Access-Challenge -> Access-Request необходимое число раз, либо завершить обмен ответом Access-Accept; или Access-Reject - если посчитает результаты обмена неудовлетворительными.

Нужно понимать, что собственно данные в клиент-серверном обмене, как и критерии завершения сессии (Accept/Reject), никак не регламентируются стандартом. Это может быть многофакторная аутентификация, когда клиент поэтапно передает серверу данные аутентификации пользователя. Либо это может быть передача данных авторизации в несколько этапов - например большого ACL для уже установленной сессии (для пользователя, прошедшего аутентификацию). В этом случае клиент не передает на сервер данные аутентификации - только имя авторизуемого пользователя и атрибут State.

Строго говоря второго "может" как раз таки быть не может - передача параметров авторизации вне диалога аутентификации называется CoA и регламентируется отдельным стандартом. Да и реализуется несколько иначе, чем тут описывается. Но мы все-таки больше о проприетарной самодеятельности, так что будем считать, что все-таки "может".

В деталях алгоритм запроса/передачи dACL выглядит следующим образом (в формате лога FreeRADIUS; оставлены только относящиеся к теме атрибуты):

Received Access-Request Id 114 from client to server
  User-Name = "username"
  User-Password = "password"
  ASA-TunnelGroupName = "DefaultWEBVPNGroup"

Sent Access-Accept Id 114 from server to client
  Cisco-AVPair += "merge-dacl=after-avpair"
  Cisco-AVPair += "ip:inacl#1=permit tcp 10.0.0.0 255.0.0.0 172.16.0.0 255.240.0.0 eq 3389"
  Cisco-AVPair += "ACS:CiscoSecure-Defined-ACL=#ACSACL#-IP-username-1556031788"

стандартный обмен Access-Request - Access-Accept - собственно аутентификация пользователя "username" с паролем "password". Обратите внимание на параметры авторизации, передаваемые клиенту (возвращаемые сервером в Access-Accept): - ip:inacl#1... - cavp-acl (ACL по первому варианту); - ACS:CiscoSecure-Defined-ACL=#ACSACL#-IP-username-1556031788 - имя dACL - (ACL по второму варианту); Формат атрибута жестко фиксирован: #ACSACL#-IP-<user-name>-<unix-timestamp> где user-name - имя пользователя, для которого запрашивается dacl, а unix-timestamp - время последнего изменения ACL в формате unixtime (используется клиентом для кэширования dacl). - merge-dacl=after-avpair - указание клиенту консолидировать cavp-acl и dacl в один ACL; порядок объединения понятен из контекста (возможен еще before-avpair).
ASA имеет такую директиву в конфигурации - в блоке "aaa-server RADIUSSERVER protocol radius", а атрибут не понимает и потому игнорируют.
FTD (версии 6.3.0 по крайней мере) директивы в конфигурации не имеет, а атрибут... тоже игнорирует - с печальными последствиями, понятными из отладочного сообщения: "dACL processing skipped: dACL would be overridden by AV-Pairs". Хотя и не должна согласно документации. Впрочем документация в этом аспекте не отличается излишней детальностью, так что возможно атрибут передается как-то иначе (хотя куда уж иначе?...), а возможно будет допилено в следующих версиях. Этот нюанс разберем ниже - после конфигурации FreeRADIUS.

Received Access-Request Id 115 from client to server
  User-Name = "#ACSACL#-IP-username-1556031788"
  User-Password = "password"
  Cisco-AVPair = "aaa:service=vpn"
  Cisco-AVPair = "aaa:event=acl-download"
  Message-Authenticator = 0xe97966ff793aa243c1157221ce187b20

Sent Access-Challenge Id 115 from server to client
  Cisco-AVPair += "ip:inacl#10=permit tcp 10.0.0.0 255.0.0.0 10.0.0.0 255.0.0.0 eq 3389"
  Cisco-AVPair += "ip:inacl#11=permit udp 10.0.0.0 255.0.0.0 any eq 53"
  ...
  Cisco-AVPair += "ip:inacl#47=permit tcp 10.0.0.0 255.0.0.0 172.16.0.0 255.240.0.0 eq 389"
  State = 0x3437
WARNING: (2) Packet is large, and possibly truncated - 4061 vs max 4096

дополнительный обмен - запрос и передача dacl. Таких сегментов/итераций Challenge-Response может быть несколько - в зависимости от общего размера dACL.
- Запрос: - User-Name/User-Password - в качестве имени пользователя передается собственно имя dACL. При этом в качестве пароля используется таковой из последнего (связнанного с текущим) запроса аутентификации.
Представленные логи сняты с обмена между Cisco ASA и FreeRADIUS - FTD атрибут с паролем при авторизации не передает, что логично - на практике бывает и двухфакторная аутентификация и тогда придет пароль из второго фактора (OTP), а его обычно нельзя использовать повторно. По этой причине, пароль в запросе авторизации не несет вообще никакой функциональности. - aaa:service=vpn, aaa:event=acl-download - атрибуты-маркеры dACL запроса, т.е. их наличие как раз и отличает dACL запрос авторизации от запросов аутентификации - различать их на основе структуры имени пользователя не слишком надежно. - Message-Authenticator - ну собственно он и есть - "Message-Authenticator"; согласно RFC 2869 - HMAC-MD5 функция от содержимого пакета и shared secret клиента. По мнению Cisco: "The presence of the Message-Authenticator attribute prevents malicious use of a downloadable access list name to gain unauthorized network access.". А по RFC 2869 - предназначен для защиты от спуфинга Access-Request с хэшированными паролями или с тунеллированием (EAP). Т.е. позволяет удостовериться, что пакет сгенерирован NAS-ом, а не кем-то другим. История появления здесь этих двух наборов атрибутов (aaa: и Message-Authenticator) тянется аж в эпоху PIX/VPN 3000, когда добавление функционала dACL создало потенциальную уязвимость, позволявшую аутентифицироваться с использованием предварительно перехваченного имени dACL, из-за чего и потребовалось ввести атрибуты, надежно отличающие запросы авторизации от запросов аутентификации (по указанной выше причине, пароль в таких запросах проверять бесполезно). За давностью лет (начало 2000-х) на cisco.com данной информации не сохранилось, но следы в инете на тему "Field Notice: FN - 61965 - CS ACS for Windows Downloadable IP Access Control List Vulnerability" найти можно. Очевидное решение - тупо заблокировать аутентификацию по маске формата имени dACL на NAS, или хотя-бы предоставить такую возможность эксплуатанту, видимо не рассматривали как противоречащее стандарту.

И кстати о стандартах - с этой т.з. нужно отметить определенную корявость этой (Cisco-вской) реализации. Дело в том, что запрос авторизации пользователя в атрибуте User-Name должен содержать... очевидно, имя авторизуемого пользователя (об идентификаторах сессии - в конце, в описании CoA). А нестандартный параметр авторизации (коим является имя dACL), должен содержаться в атрибуте, не зарезервированном стандартами протокола RADIUS (для этого как раз и придумали VSA). И в Cisco даже последовали этим путем - со стороны сервера имя dACL передается как Cisco-AVPair == "ACS:CiscoSecure-Defined-ACL=". Но на пол пути передумали, и со стороны клиента запрос передается уже с User-Name=.
Оправданием для них служит тот факт, что указанные в стандартах обязательные требования к запросам авторизации (наличие Message-Authenticator в частности), допустимы к использованию в запросах аутентификации. Т.е. гарантированного способа отличить одни от других на стороне сервера (как и ответы на них на стороне клиента) просто не существовало на момент реализации. Впрочем, оправдание это довольно слабое - Cisco обычно сама активно участвует в разработке стандартов, если это касается их продукции. Потому и не слишком понятна подобная отсебятина в данном конкретном случае...
Сейчас уже есть CoA и связанные с ним значения "Authorize Only" и "Additional-Authorization" атрибута Service-Type, но они появились на свет в контексте предложений по Dynamic Authorization Extensions (RFC3576) только в июле 2003г., а в качестве стандарта предложено в апреле 2019 - RFC8559 (этот набор RFC как раз и описывает процедуры авторизации, в т.ч. многоступенчатые). Но как показано в описании CoA в конце трактата, Cisco и здесь похерила стандарты в лучших традициях обратной совместимости (ну тут уж и правда деваться некуда - или сохранять совместимость, или полностью менять реализацию).

- Отклик: - собственно набор ACE в составе Cisco-AVPair; - маркер этапа обмена State. Стандарт не регламентирует формат содержимого - просто набор октетов; клиент просто копирует его в следующий запрос без интерпретации, а сервер определяет по нему порядок запроса в последовательности Challenge-Response. В данном случае, в атрибуте содержится номер следующего ACE, предназначенного к передаче клиенту (подробнее в коде модуля).

Так же видно отладочное предупреждение об опасно близком к 4KB размере пакета (спровоцировано для наглядности, установкой большого размера сегмента ACL - подробнее в коментариях к коду модуля).

Received Access-Request Id 116 from client to server
  User-Name = "#ACSACL#-IP-username-1556031788"
  User-Password = "password"
  State = 0x3435
  Cisco-AVPair = "aaa:service=vpn"
  Cisco-AVPair = "aaa:event=acl-download"
  Message-Authenticator = 0x8bab76f1aca7e0705f012cdcd0c6fa55

Sent Access-Accept Id 116 from server to client
  Cisco-AVPair += "ip:inacl#48=10.0.0.0 255.0.0.0 any eq 443"
  ...
  Cisco-AVPair += "ip:inacl#57=deny ip any any"

финальный обмен - оставшиеся ACE укладываются в лимит 4KB. Сервер сообщает об этом ответом Access-Accept без атрибута State.

Received Accounting-Request Id 117 from client to server
  User-Name = "username"
  Acct-Status-Type = Start
  Acct-Delay-Time = 0
  Acct-Session-Id = "CEA00093"
  Acct-Authentic = RADIUS

Received Accounting-Request Id 118 from client to server
  User-Name = "usename"
  Acct-Status-Type = Stop
  Acct-Delay-Time = 0
  Acct-Input-Octets = 46100
  Acct-Output-Octets = 10621
  Acct-Session-Id = "CEA00093"
  Acct-Authentic = RADIUS
  Acct-Session-Time = 155
  Acct-Input-Packets = 317
  Acct-Output-Packets = 11
  Acct-Terminate-Cause = User-Request

клиент реагирует на это отправкой аккаунтинг-старт пакета, в обычном случае присылаемым сразу после аутентификации. Ниже следует аккаунтинг-стоп пакет завершения сессии - т.е. disconnect пользователя. Accaunting обмен приведен просто для полноты картины - непосредственно к передаче dACL он отношения не имеет.

На этом с теорией всё (ну почти) - далее только реализация алгоритма на FreeRADIUS с необходимыми и пространными пояснениями. Нюансы работы FreeRADIUS и собственно протокола RADIUS - на сайтах проекта, FreeRADIUS HowTos и коммерческой поддержки - последний так-же содержит общедоступную документацию. Ну и просто свободным поиском - за 20 лет развития продукта в сети накопилось изрядно информации. Вопросы синтаксиса perl я вообще раскрывать не буду - пользуясь изложенной инфой и документацией, желающие запросто перепишут модуль на своем любимом языке (и нет - perl не мой любимый, но для парсинга текста и наглядности вполне подходящий).

Ответ на вопрос "почему FreeRADIUS, а не ACE/ISE?" будет раскрыт попутно - по мере обсуждения. Но если коротко - на нем эта задача поддается решению в принципе. Т.е. почему бы и нет?...

Для начала, берем за образец example.pl модуль из FreeRADIUS (скопируем его как custom-module.pl) и дополняем под наши нужды:

# Bring the global hashes into the package scope
our (%RAD_REQUEST, %RAD_REPLY, %RAD_CONFIG, %RAD_STATE, %RAD_PERLCONF);
sub acsacl {
    $RAD_CHECK{'Auth-Type'} = 'Accept';
    # атрибут, из которого процедура будет считывать ACE; по умолчанию Cisco-AVPair
    my $load_aclattr = defined $RAD_PERLCONF{'ACL-attr'} ? $RAD_PERLCONF{'ACL-attr'} : "Cisco-AVPair";
    # атрибут, в который процедура будет загружать ACE для отправки клиенту; по умолчанию Cisco-AVPair
    my $send_aclattr = defined $RAD_PERLCONF{'Send-ACL-attr'} ? $RAD_PERLCONF{'Send-ACL-attr'} : "Cisco-AVPair";
    # размер фрагмента ACL в байтах, по умолчанию 3900B (выше в логах был пример со значением 4060B)
    my $sizelimit = defined $RAD_PERLCONF{'ACL-chunksize'} ? $RAD_PERLCONF{'ACL-chunksize'} : 3900;

    if (defined $RAD_CHECK{$load_aclattr} and ref($RAD_CHECK{$load_aclattr}) eq 'ARRAY') {
        my $offset = (defined($RAD_REQUEST{'State'})) ?  $RAD_REQUEST{'State'} : '0x30';
        $offset =~ s/^0x//;
        $offset = pack("H*", $offset);
        my $startpos = $offset;
        my $size=20; # начальный размер пакета (соответствует размеру заголовка)
        for (; $offset <= $#{$RAD_CHECK{$load_aclattr}} and ($size + 8 + bytes::length($RAD_CHECK{$load_aclattr}[$offset])) <= $sizelimit; $size += (8 + bytes::length($RAD_CHECK{$load_aclattr}[$offset])) and $offset++) {
            push @{$RAD_REPLY{$send_aclattr}}, $RAD_CHECK{$load_aclattr}[$offset];
        }
        if ($offset != $#{$RAD_CHECK{$load_aclattr}}+1) {
            &radiusd::radlog(L_DBG, "Challenge: ACL fragment - ACE from " . ($startpos+1) . " to " . $offset . " of " . ($#{$RAD_CHECK{$load_aclattr}}+1) . ", $size bytes summary for " . ${RAD_REQUEST{'User-Name'}} );
            $RAD_REPLY{'State'} = '0x' . unpack('H*', $offset);
            $RAD_CHECK{'Response-Packet-Type'} = "Access-Challenge";
            return RLM_MODULE_HANDLED;
        } else {
            &radiusd::radlog(L_DBG, "Accept: last ACL fragment - ACE from " . ($startpos+1) . " to " . $offset . " of " . ($#{$RAD_CHECK{$load_aclattr}}+1) . ", $size bytes summary for " . ${RAD_REQUEST{'User-Name'}} );
            return RLM_MODULE_OK;
        }
    } else {
        &radiusd::radlog(L_DBG, "Downloadable ACL not defined ($#{$RAD_CHECK{$load_aclattr}}) for ${RAD_REQUEST{'User-Name'}}");
        #push @{$RAD_REPLY{$send_aclattr}}, "ip:inacl#1=deny ip any any";
        #return RLM_MODULE_OK;
        return RLM_MODULE_NOOP;
    }
}

Стоит отдельно остановиться на директиве Auth-Type := 'Accept'.
Явная установка атрибута Auth-Type в Accept категорически не рекомендуется. Обычно атрибут используется либо для выбора метода аутентификации, либо устанавливается в Accept/Reject модулями аутентификации по результатам проверки предоставленных клиентом данных. Но у нас-то аутентификации как раз и нет - чистая авторизация и никакой из модулей аутентификации, в общем случае, решения принять не может (обсуждалось выше на этапе 2). Так что выбор стоит только между Accept и Reject.

Вариант вернуть Access-Reject отпадает - NAS однозначно трактует такой ответ как ошибку загрузки ACL:

6   Apr 23 2019 21:03:01    109016      Can't find authorization ACL '#ACSACL#-IP-username-1556042581' on 'AAA server' for user 'username'

и не устанавливает никаких ACL (неважно - пришел ACL в составе ответа или нет). Соответственно, неявное правило deny не устанавливается и разрешает любой пользовательский трафик (в общем случае не любой, конечно - NAS может иметь свои настройки и правила для VPN трафика). Это нормальное поведение сервера, если он не имеет соответствующих пользователю dACL, либо не поддерживает такой функционал вовсе (в последнем случае запрос dACL - это запрос аутентификации несуществующего пользователя).

То-же самое произойдет и для Access-Accept без ACL, только не считается ошибкой:

6   Apr 23 2019 21:09:01    716049      Group <DfltGrpPolicy> User <username> IP <77.88.99.100> Empty SVC ACL.

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

Единственным случаем запрета установления сессии, является ошибка парсинга ACL:

3   Apr 23 2019 21:13:19    109032      Unable to install ACL '#ACSACL#-IP-username-1556043199', downloaded for user username; Error in ACE: '10.0.0.0 256.0.0.0 any eq 65537'
6   Apr 23 2019 21:13:19    716051      Group <DfltGrpPolicy> User <username> IP <77.88.99.100> Error adding dynamic ACL for user.
6   Apr 23 2019 21:13:19    113033      Group <DfltGrpPolicy> User <username> IP <77.88.99.100> AnyConnect session not allowed. ACL parse error.

Т.е. будет разорвана уже аутентифицированная пользовательская сессия. Но нет худа без добра - нет нужды ваять собственный валидатор ACL.
Кстати, такой-же эффект может возникнуть при транкатенации ACL из-за превышения лимита 4KB:

3   Apr 23 2019 21:13:19    109032      Unable to install ACL '#ACSACL#-IP-username-1556043199', downloaded for user username; Error in ACE: 'permit udp 10.0.0.0 255.0.'

Таким образом, нужно всегда возвращать Access-Accept с непустым ACL (т.е. должен существовать Default/Common Authorization Profile, в терминах Cisco ACS/ISE), если только вы не исповедуете принцип "разрешено все, что не запрещено" (что, вообще-то, ни религией, ни здравым смыслом не запрещается - так что решать Вам). Либо озаботиться политикой запрета на стороне NAS. И да - в ip:inacl правилах, remark в гордом одиночестве не поддерживается для формирования непустого ACL (с ремаркой и имплицитным deny) - загрузка такого ACL сопровождается невнятной ошибкой:

3   Apr 23 2019 21:13:23    722046      Group <DfltGrpPolicy> User <username> IP <77.88.99.100> Session terminated: Unable to establish tunnel

и сопутствующим сообщением об "Internal Error". Т.е. remark допускается только в комбинации с явным permit/deny ACE.

Здесь углубление в потроха протокола таки потребуется... С т.з. пользователя FreeRADIUS, последовательность атрибутов одного типа - это массив (тип атрибута в терминах RFC2865 - это "что за атрибут" (его имя), а не тип данных в нем хранящийся (последний в том же rfc обозначен как "Data Type")). Так что далее, говоря об "элементах" атрибута я имею в виду именно последовательность атрибутов одного типа ("Cisco-AVPair" в данном случае), с т.з. Perl API FreeRADIUS представляющих собой хэш, с ключем - именем (типом) атрибута и массивом строк (в нашем случае) в качестве значения. И кстати, согласно того же RFC2865, порядок атрибутов в запросах/ответах гарантируется только при проксировании - ни сервер, ни клиент не должны ожидать сохранения порядка следования атрибутов в пакете (даже "одноименных"). Поэтому ACE и "тянут" порядковый номер "с собой"...

Откуда ACL извлекать и присваивать как значение атрибуту - тут каждый решает для себя сам - зависит от любимого хранилища. Можно в SQL базе (и практичнее всего), я предпочитаю в AD LDAP, в multivalued атрибуте (пользовательские и групповые ACL удаляются/блокируются вместе с пользователями/группами, ну и виндовым админам так рулить проще). Этот вопрос за рамками повествования (ибо накреативить тут можно на суровый по объему трактат). Только не стоит использовать модуль rlm_files - формат Users файла не допускает многострочного описания check (control) атрибутов. А указывать несколько десятков атрибутов с ACE в одну строчку... это будет та еще конфигурация.

    } else {
        &radiusd::radlog(L_DBG, "Downloadable ACL not defined for ${RAD_REQUEST{'User-Name'}} - add stub (deny any) ACE");
        #push @{$RAD_REPLY{$send_aclattr}}, "ip:inacl#1=deny ip any any";
        #return RLM_MODULE_OK;
        return RLM_MODULE_NOOP;
    } else {
        my $offset = (defined($RAD_REQUEST{'State'})) ?  $RAD_REQUEST{'State'} : '0x30';
        if ( substr( $offset, 0, 2 ) eq "0x" ) {
            $offset =~ s/^0x//;
            $offset = pack("H*", $offset);
        }

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

        my $startpos = $offset; # переменная для логирования - первый элемент фрагмента
        my $size=20; # начальный размер пакета (соответствует размеру RADIUS заголовка)
        for (; $offset <= $#{$RAD_CHECK{$load_aclattr}} and ($size + 8 + bytes::length($RAD_CHECK{$load_aclattr}[$offset])) <= $sizelimit; $size += (8 + bytes::length($RAD_CHECK{$load_aclattr}[$offset])) and $offset++) {
            push @{$RAD_REPLY{$send_aclattr}}, $RAD_CHECK{$load_aclattr}[$offset];
        }
        if ($offset != $#{$RAD_CHECK{$load_aclattr}}+1) {
            &radiusd::radlog(L_DBG, "Challenge: ACL fragment - ACE from " . ($startpos+1) . " to " . $offset . " of " . ($#{$RAD_CHECK{$load_aclattr}}+1) . ", $size bytes summary for " . ${RAD_REQUEST{'User-Name'}} );
            $RAD_REPLY{'State'} = '0x' . unpack('H*', $offset);
            $RAD_CHECK{'Response-Packet-Type'} = "Access-Challenge";
            return RLM_MODULE_HANDLED;
        } else {
            &radiusd::radlog(L_DBG, "Accept: last ACL fragment - ACE from " . ($startpos+1) . " to " . $offset . " of " . ($#{$RAD_CHECK{$load_aclattr}}+1) . ", $size bytes summary for " . ${RAD_REQUEST{'User-Name'}} );
            return RLM_MODULE_OK;
        }

__

По модулю особо больше обсуждать нечего - перейдем к конфигурации FreeRADIUS. За рамками обсуждения останутся вопросы аутентификации (ибо специфично для каждой конкретной инсталляции) и варианты хранения ACL (слишком богатое поле для творчества) - примеры будут в формате кофигурационных файлов FreeRADIUS.

Итак, наш пользователь:

#/etc/raddb/users
username Cleartext-Password := "password", ASA-TunnelGroupName =* ""
    Cisco-AVPair += "ACS:CiscoSecure-Defined-ACL=#ACSACL#-IP-%{%{Stripped-User-Name}:-%{%{User-Name}:-None}}-%l"

Формат имени был рассмотрен выше. Здесь только один нюанс - в целях упрощения примера, для формирования имени dACL используется переменная "%l" - timestamp. Т.е. кэширование на стороне NAS (клиент) не работает (время создания dACL соответствует времени формирования ответа сервером). Процесс аутентификации и запроса dACL от NAS почти полностью будет соответствует примеру выше - отсутствуют только cavp-acl и соответственно merge-dacl:

Received Access-Request Id 121 from client to server
  User-Name = "username"
  User-Password = "password"

Sent Access-Accept Id 121 from server to client
  Cisco-AVPair += "ACS:CiscoSecure-Defined-ACL=#ACSACL#-IP-username-1556032425"

Received Access-Request Id 122 from client to server
  User-Name = "#ACSACL#-IP-username-1556032425"
  User-Password = "password"
  Cisco-AVPair = "aaa:service=vpn"
  Cisco-AVPair = "aaa:event=acl-download"
  Message-Authenticator = 0x6101a78c76855c2aa35454cf3b17cfb7

На данном этапе желательно выделить обработку запроса dACL в отдельный virtual-server - как было замечено выше, это уже чистая авторизация и мы должны быть твердо уверены, что не вернули Access-Accept на запрос аутентификации с именем пользователя в формате dACL:

#/etc/raddb/hints
DEFAULT     NAS-IP-Address == "client_ip", Cisco-AVPair =~ "aaa:event=acl-download", Cisco-AVPair =~ "aaa:service=ip-admission|aaa:service=vpn", User-Name =~ "#ACSACL#-IP-(.*)-[0123456789]*", Message-Authenticator =* "", ASA-TunnelGroupName !* ""
    Hint = "ACSACL",
    User-Name := "%{1}@DACL"

Здесь мы убеждаемся, что запрос от NAS содержит характерные для запроса dACL атрибуты (и не содержит характерные для запроса на аутентификацию - ASA-TunnelGroupName), выделяем имя пользователя из имени dACL, и добавляем к нему realm=DACL (для последующего перехвата в файле proxy.conf).

Если обратить внимание на проверку Cisco-AVPair =~ "aaa:service=ip-admission|aaa:service=vpn", возникает вопрос - почему aaa:service проверяется на два значения (ip-admission и vpn)? Ответ кроется в Cisco ASA Series CLI Configuration Guide, где утверждается, что запрос dACL сопровождается парой атрибутов aaa:service=ip-admission и aaa:event=acl-download, установленным Message-Authenticator, да еще и в сопровождении "null password attribute". На деле, я никогда не видел подобного поведения ни от ASA, ни от FTD - запрос dACL всегда сопровождается парой aaa:service=vpn и aaa:event=acl-download, Message-Authenticator и непустым паролем (обсуждалось выше). Возможно это копипаста из документации к PIX или VPN3000 - под рукой для проверки таковых нет, а возможно просто отсебятина документописателей из Сisco - в любом случае верим на слово и проверяем оба значения.

Так же стоит остановиться на Message-Authenticator. Во FreeRADIUS есть возможность наглухо заблокировать запросы без Message-Authenticator (директива require_message_authenticator = yes), но применяется она на клиента в целом - на отдельные запросы её не повесить. А запросы аутентификации от ASA/FTD приходят без Message-Authenticator (наличие Message-Authenticator согласно RFC5080 обязательно только для запросов авторизации, т.е. как раз для запроса dACL). Так что проверяем наличие Message-Authenticator самостоятельно, а верификацию значения пусть проводит сервер в соответствии с RFC 2869 (согласно требованиям стандарта, при наличии Message-Authenticator в запросе, сервер обязан вычислить актуальное значение MA для пакета и молча отбросить его при несоответствии переданного значения вычисленному).

Указываем virtual_server = dacl, для обработки запросов с realm == DACL:

#/etc/raddb/proxy.conf
realm DACL {
        virtual_server = dacl
        hints
        delimiter = @
        format =  suffix
}

и создаем предельно лаконичную конфигурацию для virtual_server == dacl (ну или как принято - ссылку на таковую в /etc/raddb/sites-available):

#/etc/raddb/sites-enabled/dacl
server dacl {
authorize {
        load.dacl
        if (Hint == "ACSACL") {
            update control {
                &Cisco-AVPair += "ip:inacl#10=permit tcp 10.0.0.0 255.0.0.0 10.0.0.0 255.0.0.0 eq 3389",
                &Cisco-AVPair += "ip:inacl#11=permit udp 10.0.0.0 255.0.0.0 any eq 53",
                ...
                &Cisco-AVPair += "ip:inacl#45=permit tcp 10.0.0.0 255.0.0.0 172.16.0.0 255.240.0.0 eq 443",
                &Cisco-AVPair += "ip:inacl#46=10.0.0.0 255.0.0.0 10.0.0.0 255.0.0.0 eq 443",
                ...
                &Cisco-AVPair += "ip:inacl#57=deny ip any any"
            }
        }
        acsacl.pl
}
authenticate {
}
}

Используются только два модуля - acsacl.pl и load.dacl (можно добавить логирование). В общем из имен и так понятно: первый - это наш модуль обработки dACL, второй - загрузка пользовательского ACL на основе файла конфигурации. Загрузка группового ACL производится непосредственно в описании virtual-server (ибо, как упоминалось ранее, грузить его из файла users - ну его нафиг). Добавим их определения в конфигурацию сервера:

#/etc/raddb/mods-enabled/acsacl.pl
perl acsacl.pl {
        filename = ${modconfdir}/perl/custom-module.pl
        func_authorize = acsacl
        config {
                ACL-attr = "Cisco-AVPair"
                ACL-chunksize = 4000
        }
}
#/etc/raddb/mods-enabled/load.dacl
files load.dacl {
        filename = ${modconfdir}/${.:name}/${.:instance}/dacl
}
  #/etc/raddb/mods-config/files/load.dacl/dacl
  # пользовательский ACL
  username@DACL     Hint == "ACSACL", Cisco-AVPair += "ip:inacl#1=permit tcp 10.0.0.0 255.0.0.0 172.16.0.0 255.240.0.0 eq 3389"

И в итоге получаем:

  Sent Access-Challenge Id 122 from server to client
    Cisco-AVPair += "ip:inacl#1=permit tcp 10.0.0.0 255.0.0.0 172.16.0.0 255.240.0.0 eq 3389"
    Cisco-AVPair += "ip:inacl#10=permit tcp 10.0.0.0 255.0.0.0 10.0.0.0 255.0.0.0 eq 3389"
    Cisco-AVPair += "ip:inacl#11=permit udp 10.0.0.0 255.0.0.0 any eq 53"
    ...
    Cisco-AVPair += "ip:inacl#45=permit tcp 10.0.0.0 255.0.0.0 172.16.0.0 255.240.0.0 eq 443"
    State = 0x3435

  Received Access-Request Id 123 from client to server
    User-Name = "#ACSACL#-IP-username-1556032425"
    User-Password = "password"
    State = 0x3435
    Cisco-AVPair = "aaa:service=vpn"
    Cisco-AVPair = "aaa:event=acl-download"
    Message-Authenticator = 0x40eeae8eccb5fa8895fbc4068f041575

  Sent Access-Accept Id 123 from server to client
    Cisco-AVPair += "ip:inacl#46=10.0.0.0 255.0.0.0 any eq 443"
    ...
    Cisco-AVPair += "ip:inacl#57=deny ip any any"

  Received Accounting-Request Id 124 from client to server
    User-Name = "username"
    Acct-Status-Type = Start
    Acct-Delay-Time = 0
    Acct-Session-Id = "CEA00097"
    Acct-Authentic = RADIUS

__

Собственно всё. Дальше можно только уточнить упомянутое "нафига?"...

и удовлетворенно почесываем ЧСВ :-) Кастомный CoA-Server для OCServ потыкаем палочкой в отдельном трактате.