Управление доступом абонентов

Любой оператор связи вынужден решать задачу управления доступом абонентов. Абоненты получают возможность использовать ресурсы сети оператора связи — обычно для доступа во «внешние сети».

Непосредственно предоставлением доступа занимается NAS (BRAS, BNG) — специализированная форма сетевого маршрутизатора. Большой объём абонентского трафика не позволяет анализировать каждый проходящий пакет на control plane, поэтому на data plane создаётся некое «правило доступа», определяющее параметры пропуска трафика. Абонентские пакеты, не попадающие ни под одно правило, считаются «неклассифицированным трафиком», выборка из которого передаётся на control plane для принятия решения.

NAS не принимает самостоятельного решения о предоставлении доступа. Решение принимает AAA-сервер. Стандартным протоколом взаимодействия NAS и AAA является RADIUS.

В LANBilling используется LBarcd — RADIUS-сервер собственной разработки.

Управление доступом состоит из следующих этапов:

  1. Идентификация абонента.
  2. Аутентификация абонента.
  3. Авторизация абонента.

NAS отправляет AAA-серверу RADIUS-запрос, содержащий необходимые идентификаторы абонента и аутентификационную информацию (при наличии). На основании полученного ответа NAS создаёт правило доступа, отвечающее за пропуск абонентского трафика.

Схемы управления доступом

Схема доступа — решение задачи управления доступом.

Понятие «схема доступа» можно рассматривать в широком смысле и в узком смысле.

  • в узком смысле — конкретное решение для конкретного клиента в рамах конкретной установки.
  • в широком смысле — политика управления доступом, применяемая для решения широкого круга задач.

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

Схемы управления доступом делятся на два больших класса: PPP и IPoE.

PPP

В схеме PPP для управления доступом используется протокол PPP (семейство взаимосвязанных протоколов). У операторов связи используется в виде PPPoE и PPTP.

PPPoE работает на сетевом уровне. PPTP работает на транспортном уровне, поэтому для работы PPTP необходима первичная конфигурация абонентов. Поэтому протокол PPTP используется в современных условиях относительно редко.

IPoE

«IPoE» — жаргонное название набора технологий управления доступом. В отличие является разрозненным набором технологий, обычно реализуемых на стороне оборудования доступа.

Для конфигурации абонентского оборудования используется протокол DHCP. Что очень занятно, так как DHCP не предназначен для конфигурирования маршрутизаторов.

Классификация схем IPoE

В схеме PPP используется только один режим создания абонентских сессий. В схеме IPoE возможно несколько вариантов.

1. Старт сессии по первому неклассифицированному IP-пакету

Жаргонное название — «L3».

Если логика data plane не смогла классифицировать первый IP-пакет (в особо запущенных случаях — ARP), то пакет передаётся на control plane для анализа. NAS запрашивает у AAA доступ и по результатам ответа создаёт необходимые правила доступа на data plane и абонентскую сессию. (Обычно сессия живёт на control plane).

Абоненту должен быть присвоен IP-адрес, обычно по DHCP.

2. Старт сессии по первому неклассифицированному ethernet-пакету

Жаргонное название — «L2».

Логика идентична, но работает на сетевом уровне. Абоненту также должен быть присвоен абонентский адрес. Разница только в форме правил на data plane и требованиям к сети [абонентское оборудование должно находиться в том же сегменте ethernet].

3. Старт сессии по созданию виртуального интерфейса

Рассматривается только кейс 1:1 vlan

NAS создаёт виртуальные интерфейсы на основе тегированного ethernet. Пакет с неизвестным тегом передаётся на control plane для анализа. NAS запрашивает у AAA доступ и по результатам создаёт «тегированный» виртуальный интерфейс. В этом схема похожа на PPP.

В общем случае абоненту не требуется присваивать IP-адрес: сессия может создаваться по первом пакету DHCP.

4. DHCP-сервер

NAS выполняет обязанности DHCP-сервера. DHCP-пакеты передаются на control plane для анализа. Обычно в роли инициирующего пакета выступает DHCPDISCOVER. В большинстве случаев NAS не управляет назначением/выдачей IP-адресов самостоятельно, а запрашивает доступ у AAA. Полученный от AAA IP-адрес используется в DHCPOFFER. По завершении DORA-транзакции создаётся абонентская сессия.

5. DHCP-relay

Схема аналогична предыдущей, но NAS не является DHCP сервером а только инспектирует проходящие пакеты.

6. DHCP-proxy

Гибрид двух предыдущих схем. NAS ведёт обмен с абонентом от имени существующего DHCP-сервера. Может самостоятельно отвечать абоненту.

Идентификаторы

  1. Логин. Используется только в схеме PPP.

  2. Ethernet MAC-адрес (Calling-Station-Id, chaddr). Может использоваться в PPPoE или IPoE. Может выступать в качестве самостоятельного идентификатора абонента [но крайне редко в PPPoE], или в качестве дополнительной информации, используемой логикой авторизации.

  3. Пара vlan. Используется в IPoE [теоретически может использоваться в PPP, но на практике не используется].

  4. IP-адрес. Используется в IPoE для абонентов со статической конфигурацией. Использование DHCP опционально, но не запрещено.

  5. 82-я опция (DHCP). Используется исключительно в IPoE. Оборудование доступа (коммутатор, OLT) изменяет проходящие сообщения DHCP, и добавляют дополнительное поле (опцию) с идентификатором абонента (обычно в форме устройство-порт).

  6. 82-я опция (PPPoE). Некоторые модели операторского оборудования доступа могут изменять проходящие PADI пакеты, и добавлять уникальный идентификатор абонента. [На практике используется достаточно редко, обычно как дополнительная информация для логики авторизации].

Мотивация

Главная сложность — наличие большого числа слабо связанных механизмов, при минимуме политик. Особенно если рассматривать IPoE.

Любая фиксированная логика будет работать одинаково плохо. Попытки «улучшить» приведут к потере управляемости.

[Одному клиенту «починили» — другому «сломали», и никто не знает как это всё работает. И да, мы это всё проходили на практике.]

Скрипт позволяет уйти от фиксированной логики, но не препятствует созданию произвольного количества примитивов — кусков логики управления доступом.

При решении задачи управления доступом, автор скрипта необходимым образом комбинирует примитивы. В результате, с одной стороны, для каждого клиента (и/или установки) разрабатывается индивидуальное решение; с другой стороны используются «штатные» [т.е. понятные, описанные, протестированные] механизмы.

Логика запуска скрипта

В перспективе скрипт должен управлять всеми аспектами логики управления доступом абонентов. Сейчас возможно решить только задачу идентификации/аутентификации абонентов.

Скрипт выполняется при обработке RADIUS сообщения Access-Request.

Скрипт выполняется в два этапа.

На этапе конфигурации происходит передача управления в скрипт, так как будто бы он является неявной функцией. [В терминах языка lua это называется chunk]. На данном этапе происходит создание и конфигурация модели управления доступом.

Например:

-- Сюда передано управление
-- На этапе конфигурации разрешено исполнять любую логику
print("Configuration phase")
-- В журнал агента будет зафиксировано сообщение «Configuration phase»

-- Создание модели
local lb = require "lb"
local plugin = lb.require("20250701")

local function access_control(ctx)
    -- Здесь размещается логика управления доступом
    ...
end

plugin:connect('access-control', access_control)

-- Регистрация модели
plugin:register("Demo")

-- Модель зарегистрирована, на следующем этапе будет вызван метод
-- `access_control`, в качестве параметра будет передана сущность
-- «Контекст аутентификации»

На этапе исполнения управление передаётся зарегистрированной модели, которая и определяет дальнейшую логику управления доступом.

Подключение скрипта

Опция radius-external-auth-script задаёт путь к файлу скрипта.

Включение опции отключает все остальные методы аутентификации.

При внесении изменений в скрипт перезагружать агент не требуется.

Особенности среды исполнения

  1. Запрещено использовать библиотеки os и io. [Запрос через require завершится с ошибкой.]
  2. При запуске в глобальную область видимости помещена только библиотека base. Все остальные стандартные библиотеки необходимо запросить явным образом через require.
  3. Запрещена загрузка сторонних модулей.
  4. Установлены ограничения потребляемой памяти и время исполнения скрипта. [Некая защита от зацикливания, понятно дело, не абсолютная.]
  5. Вывод метода print (base.print) перенаправлен в журнал агента.
  6. Скрипту разрешён только определённый набор побочных эффектов.

API ver. 20250701

Для решения задач управления доступом используется «версионный» API. В перспективе будет существовать несколько несовместимых (!!!) версий, но в настоящий момент существует только одна.

Для получения «точки входа» в API используется метод require.

local lb = require "lb"
local plugin = lb.require("20250701").
...

Точка входа хранится в переменной plugin.

[lb и plugin — обычные переменные языка lua. При желании можно использовать любой идентификатор.]

Метод plugin:connect

Метод plugin:connect отвечает за подключение «нефиксированных» элементов модели.

В настоящий момент возможен только один вариант использования — подключение логики идентификации абонента.

local lb = require "lb"
local plugin = lb.require("20250701").

local function access_control(ctx)
    -- Здесь размещается логика управления доступом
    ...
end

plugin:connect('access-control', access_control)
plugin:register("Demo")

Метод access_control принимает сущность «Контекст аутентификации» и должен вернуть либо вернуть сущность «Решение» [положительное или отрицательное], либо инициировать ошибку lua [обычно при помощи assert].

Метод plugin:register

Метод plugin:register регистрирует модель управления доступом. В качестве параметра передаётся любой строковый объект, который используется в качестве «человекочитаемого идентификатора».

Сущность «Контекст аутентификации»

Логика управления доступом осуществляется через сущность «Контекст аутентификации».

Контекст аутентификации — гибридная сущность в равной степени существующая как в среде исполнения lua, так и в среде исполнения LBarcd.

Попытки использовать контекст аутентификации вне предписанного интерфейса приведут к ошибке времени исполнения.

Понятия предметной области

С контекстом аутентификации ассоциирован набор т.н. понятий предметной области (ППО).

Метод ctx:get возвращает строковое представление ППО по имени. [Т.е. копию значения.]

...
local function access_control(ctx)
    local framed_ip_address = assert(ctx:get('Framed-IP-Address'), "Error")
    ...
end
...

[ctx:get возвращает nil, если ППО не существует или не найдено]

В настоящий момент определены следующие ППО

  • User-Name Содержимое атрибута User-Name сообщения Access-Request.
  • Framed-IP-Address Содержимое атрибута Framed-IP-Address сообщения Access-Request. Семантика зависит о контекста.
  • NAS-Port-Id Содержимое атрибута NAS-Port-Id сообщения Access-Request.
  • Calling-Station-Id нормализованное значение ethernet MAC-адреса абонентского устройства. Формируется из атрибута Calling-Station-Id сообщения Access-Request.
  • Agent-Circuit-Id, Agent-Circuit-Id нормализованное значение соответствующих субопций 82-й опции. Формируется из поддерживаемых вендорских атрибутов сообщения Access-Request. Если значение субопции содержит хотя бы один непечатный символ, то всё значение кодируется hex.

Метод ctx:get

Метод ctx:get возвращает строковое представление ППО по имени или nil, если ППО не найдено или не существует.

ctx:get возвращает нормализованное значение, например

local chaddr = assert(ctx:get('Calling-Station-Id'), "Error")

всегда возвращает ethernet MAC-адрес в формате XX:XX:XX:XX:XX:XX.

Значение субопций 82-й опции нормализуются по правилу:

  • если значение содержит хотя бы один непечатный символ, то всё значение кодируется в hex.

Сущность «Результат»

Логика управления доступом должна либо вернуть сущность «Результат» (положительный либо отрицательный), либо инициировать ошибку lua, например, при помощи assert.

Сущность «Результат» — гибридная сущность, в равной степени существующая как в среде исполнения lua, так и в среде исполнения LBarcd.

Создать сущность «Результат» возможно только при помощи специальных конструкторов ctx:accept или ctx:reject.

Созданная сущность неизменяема, попытка взаимодействия через интерфейс lua приведёт к ошибке уровня исполнения.

Метод ctx:reject используется относительно редко, более эффективно инициировать ошибку lua.

local function access_control(ctx)
    local subscriber = ... -- получи абонента
    -- Если абонент не найден (nil), то инициируй ошибку
    assert(subscriber, "Reason")
    -- Положительный результат
    return ctx:accept { subscriber = subscriber }
end

Метод ctx:accept

Метод ctx:accept является конструктором сущности «Результат».

На вход должна быть передана lua-таблица с заполненным ключом subscriber (абонент).

Метод ctx:reject

Метод ctx:reject является конструктором сущности «Результат».

На вход должна быть передана lua-таблица с заполненным ключам reason.

Значение reason будет зафиксировано в журнале агента и в журнале аутентификаций агента.

N.B. return ctx:reject {reason = "Reason"} и assert(nul, "Reason") эквиваленты.

Метод ctx:check_password

Метод ctx:check_password проверяет значение пароле в атрибуте User-Password сообщения Access-Reject.

Пароль никогда не передаётся на сторону скрипта.

В случае IPoE в Access-Request передаётся фиксированные значения User-Name и User-Password. Проверка фиксированного User-Password применяется для проверки разделяемого секрета в случаях, когда не передан атрибут Message-Authenticator.

[Всегда необходимо передавать Message-Authenticator]

Так как для в IPoE не используется User-Password для аутентификации абонента, в случае некорректно заданного разделяемого секрета Access-Request будет обработан без ошибок на стороне AAA, но ответ не примет NAS из-за несовпадения секретов.

Явная проверка фиксированного пароля позволяет избежать подобной ситуации.

Например,

User-Name = "cisco"
User-Password = "cisco"
local string = require "string"
local function access_control(ctx)
    user_name = ctx:get("User-Name") or "<None>".
    assert("cisco" == user_name,
        string.format("Unexpected User-Name: %s", user_name))
    assert(ctx:check_password("cisco"),
        "Unexpected fixed password. Use 'cisco'")
    ...
end

Сущность «Абонент»

Сущность абонент содержит информацию об идентифицированном абоненте.

Сущность «Абонент» — гибридная сущность, в равной степени существующая как в среде исполнения lua, так и в среде исполнения LBarcd.

Создать сущность можно только при помощи специальных конструкторов контекста аутентификации.

Попытки использовать сущность «Абонент» вне предписанного интерфейса приведут к ошибке времени исполнения.

Доступные методы:

  • subscriber:id — идентификатор в БД;
  • subscriber:name — строковый идентификатор абонента (логин).

Метод ctx:subscriber_by_id

Метод subscriber_by_id ищет абонента по идентификатору учётной записи абонента в БД.

Метод создаёт и возвращает сущность «Абонент» или nil, если не найден.

local function access_control(ctx)
    local subscriber_id = ...  -- Идентификатор абонента
    local subscriber = ctx:subscriber_by_id(subscriber_id)
    assert(subscriber,
        string.format("Unexpected subscriber_id: %d", subscriber_id))
    print(string.format("Found subscriber id: %d, name: %s",
          subscriber:id(), subscriber:name()))
    return ctx:accept { subscriber = subscriber }
end

Метод ctx:subscriber_by_name

Метод ctx:subscriber_by_name ищет абонента по имени учётной записи абонента.

Метод создаёт и возвращает сущность «Абонент» или nil, если не найден.

local function access_control(ctx)
    local subscriber_name = ...  -- Имя абонента
    local subscriber = ctx:subscriber_by_id(subscriber_name)
    assert(subscriber,
        string.format("Unexpected subscriber name: %s", subscriber_name))
    print(string.format("Found subscriber id: %d, name: %s",
          subscriber:id(), subscriber:name()))
    return ctx:accept { subscriber = subscriber }
end

Сущность «Операторское оборудование»

Сущность «Операторское оборудование» содержит информацию об операторском оборудовании доступа на котором терминируется абонентская линия абонента.

Сущность «Операторское оборудование» — гибридная сущность, в равной степени существующая как в среде исполнения lua, так и в среде исполнения LBarcd.

Создать сущность можно только при помощи специальных конструкторов контекста аутентификации.

Попытки использовать сущность «Операторское оборудование» вне предписанного интерфейса приведут к ошибке времени исполнения.

Доступные методы

  • device:id — идентификатор в БД;
  • device:label — человекочитаемое имя;
  • device:type — тип (“switch” | “GPON” | nil);
  • device:ip — строковое представление IP-адреса;
  • device:mac — нормализованное представление ethernet MAC-адреса;
  • device:opt82 — текстовое представление 82-й опции (машиночитаемый идентификатор).

[MAC-адрес всегда в формате XX:XX:XX:XX:XX.]

local access_control(ctx)
    local remote_id = assert(ctx:get("Agent-Remote-Id"), "No Remote-Id")

    local device = ...  -- Операторское оборудование
    print(string.format(
        "Found device id: %d, label: %s, type: %s, ip: %s, mac: %s, opt82: %s",
        device:id(), device:label(), device:type(), device:ip(), device:mac(),
        device:opt82()))
    ...
end

Методы device_by_id, device_by_opt82, device_by_mac, device_by_ip

Метод device_by_X ищет операторское оборудование по одному из идентификаторов.

Метод создаёт и возвращает сушность «Абонентское устройство» или nil, если устройство не найдено.

local access_control(ctx)
    local remote_id = assert(ctx:get("Agent-Remote-Id"), "No Remote-Id")

    local device = assert(ctx:device_by_opt82(remote_id), "No device found");
    print(string.format(
        "Found device id: %d, label: %s, type: %s, ip: %s, mac: %s, opt82: %s",
        device:id(), device:label(), device:type(), device:ip(), device:mac(),
        device:opt82()))
    ...
end

Сущность «Порт»

Сущность «Порт» содержит информацию «физическом» порте операторского оборудования доступа на котором терминируется абонентская линия абонента.

Сущность «Порт» — гибридная сущность в равной степени существующая как в среде исполнения lua, так и в среде исполнения LBarcd.

Создать сущность можно только при помощи специальных конструкторов контекста аутентификации.

Попытки использовать сущность «Порт» вне предписанного интерфейса приведут к ошибке времени исполнения.

Доступные методы:

  • port:id — идентификатор в БД;
  • port:name — имя (машиночитаемое);
  • port:vlan — текстовое представление vlan;
  • subscriber_id — идентификатор абонента в БД.
local access_control(ctx)
    local port = ...  -- Порт операторского оборудования
    print(string.format(
        "Found port port_id: %d, name: %s, vlan: %s, subscriber_id: %s",
        port:id(), port:name(), port:vlan(), port:subscriber_id() or "<None>"))

    ...
end

Метод ctx:port_by_id

Метод ctx:port_by_id ищет порт операторского оборудования по идентификатору в БД.

Метод создаёт и возвращает сущность «Порт» или nil, если не найден.

local access_control(ctx)
    local port_id = ...  -- Идентификатор порта
    local port = assert(ctx:port_by_id(port_id), "Port not found")
    print(string.format(
        "Found port port_id: %d, name: %s, vlan: %s, subscriber_id: %s",
        port:id(), port:name(), port:vlan(), port:subscriber_id() or "<None>"))
    ...
end

Метод ctx:port_by_name

Метод ctx:port_by_name ищет порт операторского оборудования по сущности «Операторское оборудование» и (машиночитаемому) имени порта.

Метод создаёт и возвращает сущность «Порт» или nil, если не найден.

[Сущность «Операторское оборудование» должна быть должным образом создана одним из методов-конструкторов контекста аутентификации.]

local access_control(ctx)
    local circuit_id = ctx:get("Agent-Circuit-Id")  -- Машиночитаемое имя порта
    local remote_id = assert(ctx:get("Agent-Remote-Id"), "No Remote-Id")

    local device = assert(ctx:device_by_opt82(remote_id), "No device found");
    print(string.format(
        "Found device id: %d, label: %s, type: %s, ip: %s, mac: %s, opt82: %s",
        device:id(), device:label(), device:type(), device:ip(), device:mac(),
        device:opt82()))

    local port = assert(ctx:port_by_name(device, circuit_id), "Port not found")
    print(string.format(
        "Found port port_id: %d, name: %s, vlan: %s, subscriber_id: %s",
        port:id(), port:name(), port:vlan(), port:subscriber_id() or "<None>"))
    ...
end

Сущность «Виртуальный порт»

Сущность «Виртуальный порт» содержит информацию о виртуальном порте операторского оборудования доступа на котором терминируется абонентская линия абонента.

Сущность «Виртуальный порт» — гибридная сущность в равной степени существующая как в среде исполнения lua, так и в среде исполнения LBarcd.

Создать сущность можно только при помощи специальных конструкторов контекста аутентификации.

Попытки использовать сущность «Виртуальный порт» вне предписанного интерфейса приведут к ошибке времени исполнения.

Доступные методы:

  • vport:id — идентификатор в БД;
  • vport:name — имя (машиночитаемое);
  • vport:subscriber_id — идентификатор абонента в БД.

[Сущность «Абонент» можно получить по идентификатору БД при помощи ctx:subscriber_by_id.]

Метод ctx:vport_by_id

Метод ctx:vport_by_id ищет виртуальный порт операторского оборудования по идентификатору в БД.

Метод создаёт и возвращает сущность «Виртуальный порт» или nil, если не найден.

local function access_control(ctx)
    local vport_id = ... -- Идентификатор виртуального порта
    local vport = assert(ctx:vport_by_id(vport_id), "No vport found")
    print(string.format("Found vport vport_id: %d, name: %s, subscriber_id: %s",
        vport:id(), vport:name(), vport:subscriber_id() or "<None>"))
end

Метод ctx:vport_by_name

Метод ctx:vport_by_name ищет виртуальный порт операторского оборудования по сущности «Порт» и (машиночитаемому идентификатору порта).

Метод создаёт и возвращает сущность «Виртуальный порт» или nil, если не найден.

[Сущность «Порт» должна быть должным образом создана одним из методов-конструкторов контекста аутентификации.]

local access_control(ctx)
    local circuit_id = ctx:get("Agent-Circuit-Id")
    local remote_id = assert(ctx:get("Agent-Remote-Id"), "No Remote-Id")

    local device = assert(ctx:device_by_opt82(remote_id), "No device found");
    print(string.format(
        "Found device id: %d, label: %s, type: %s, ip: %s, mac: %s, opt82: %s",
        device:id(), device:label(), device:type(), device:ip(), device:mac(),
        device:opt82()))

    local port = assert(ctx:port_by_name(device, circuit_id), "Port not found")
    print(string.format(
        "Found port port_id: %d, name: %s, vlan: %s, subscriber_id: %s",
        port:id(), port:name(), port:vlan(), port:subscriber_id() or "<None>"))

    local vport = assert(ctx:vport_by_name(port, vport_name), "No vport found")
    print(string.format("Found vport vport_id: %d, name: %s, subscriber_id: %s",
        vport:id(), vport:name(), vport:subscriber_id() or "<None>"))

    ...
end

Сущность «Vlan»

Сущность «Vlan» содержит информацию о ethernet vlan.

Сущность «Vlan» — гибридная сущность в равной степени существующая как в среде исполнения lua, так и в среде исполнения LBarcd.

Создать сущность можно только при помощи специальных конструкторов контекста аутентификации.

Доступные методы:

  • vlan:id — идентификатор в БД;
  • vlan:svlan — svlan (outer vlan);
  • vlan:cvlan — cvlan (inner vlan);
  • __string — допустимо использовать в стоковом контексте.
local access_control(ctx)
    local svlan, cvlan = ...  -- Теги: svlan и cvlan
    local vlan = assert(ctx:vlan(svlan, cvlan), "Vlan not found")
    print(string.format("Found vlan vlan_id: %d, %s", vlan:id(), vlan))
    ...
end

Метод ctx:vlan

Метод ctx:vlan ищет vlan по паре svlan:cvlan.

Метод принимает два параметра:

  • если передан только один параметр, он считается svlan;
  • если передано два параметра, они считаются парой svlan:cvlan;

Метод создаёт и возвращает сущность «Vlan» или nil, если не найдена.

[Vlan должен быть создан на стороне LB.]

local access_control(ctx)
    local svlan, cvlan = ...  -- Теги: svlan и cvlan
    local vlan = assert(ctx:vlan(svlan, cvlan), "Vlan not found")
    print(string.format("Found vlan vlan_id: %d, %s", vlan:id(), vlan))
    ...
end

Метод ctx:port_by_vlan

Метод ctx:port_by_vlan ищет порт операторского оборудования по сущности «Vlan».

Метод создаёт и возвращает сущность «Порт» или nil, если не найдена.

[Сущность «Vlan» должна быть создана при помощи методов-конструкторов контекста аутентификации.]

local access_control(ctx)
    local svlan, cvlan = ...  -- Идентификаторы svlan:cvlan
    local vlan = assert(ctx:vlan(svlan, cvlan), "Vlan not found")
    print(string.format("Found vlan vlan_id: %d, %s", vlan:id(), vlan))

    local port = assert(ctx:port_by_vlan(vlan), "Port not found")
    print(string.format(
        "Found port port_id: %d, name: %s, vlan: %s, subscriber_id: %s",
        port:id(), port:name(), port:vlan(), port:subscriber_id() or "<None>"))
    ...
end