Всем привет. Я сетевой инженер в интернет провайдере. Примерно год назад начал внедрение Netbox, для документации сети. Естественно встал вопрос заполнения. Основные маршрутизаторы и коммутаторы добавили руками, их не сильно много, это ядро и опорная сеть. И довольно много саб интерфейсов. Далее захотелось добавить коммутаторы доступа, а их несколько сотен, и пара десятков моделей. И вот тут уже стало больно от одной только мысли, добавлять их руками.
Добавлять устройства будем через API Нетбокса. Система добавления там замудрёная, но не сложная. Сначала я покажу скрипт, как я добавил пачку свичей, а потом уже скрипты, которыми собрал информацию. Скриптом добавления можно пользоваться на постоянной основе, получается быстрее чем через web интерфейс, даже если устройство одно.
Берём json файлик с коммутаторами.
[
{
"hostname": "TEST_SCRIPT1",
"interface": "vlan4090",
"ipaddress": "192.168.0.6/24",
"mac_address": "33:10:FF:EE:11:22",
"model": "DGS-3120-24SC",
"siteslug": "site_test",
"swroleslug": "access-switch"
},
{
"hostname": "TEST_SCRIPT2",
"interface": "vlan4090",
"ipaddress": "192.168.0.66/24",
"mac_address": "33:10:AA:BB:CC:DD",
"model": "MES2348B",
"siteslug": "site_test",
"swroleslug": "access-switch"
}
]
Здесь у нас имя устройства, ip адрес, мак адрес, интерфейс к которому будет привязка мака и ip. Модель коммутатора, сайт, и роль. Мне такого набора данных достаточно. Если кому то будет мало, то расширить будет не проблема.
Модель коммутатора должна уже быть в Нетбоксе. И должна быть записана так же как и в самом Нетбоксе.
Сайт и роль указаны slug. Это "Подстрока" в Нетбоксе.

Начнём с создания файла .env, в нём будем хранить переменные которые лучше не держать в самом коде. И при наличии нескольких сетей, удобней редактировать один файл, а не каждый скрипт.
API_KEY = "Token"
URLNB = "https://nb.youdomain.ru"
SWITCHES_FILE = 'add_nmap_192.168.0.0.json'
IPSCAN = '192.168.0.0'
Тут мы указываем API токен для Нетбокса. URL Нетбокса, и json файл, в котором у нас коммутаторы. И IPSCAN, он нужен будет дальше, а в данном скрипте он просто формирует имя для файла логов.
Далее создаём сам скрипт.
Прописываем импорты.
import os
import json
import requests
import logging
import logging.handlers
from dotenv import load_dotenv
Все действия скрипта я буду логировать, что бы в случае ошибки было видно на каком шаге она произошла, или убедиться, что всё прошло хорошо.
Далее я напишу класс с запросами. Можно было обойтись и без классов, но так код более понятный и структурированный.
Кратко о добавлении устройств через API:
Создаём устройство (обязательные поля: имя, модель, сайт, роль);
Создаём интерфейс управления (виртуальный интерфейс vlan4090 в нашем примере);
Создаём ip на интерфейсе;
Создаём мак адрес на интерфейсе;
Делаем ip адрес основным;
Делаем мак адрес основным.
Пункты 5 и 6, не обязательные, я делаю их для удобства. Далее при работе с оборудованием удобно выдёргивать через API именно ip управления, а не все подряд. Ведь ip адресов на одном устройстве может быть не один десяток.
Код класса
class NbPostDevice:
'''
При создании экземпляра мы будем передавать в него все
данные, что есть у нас в файле
'''
def __init__(self, ipaddress, hostname, model, mac_address, interface, siteslug, swroleslug):
self.ipaddress = ipaddress
self.hostname = hostname
self.model = model
self.mac_address = mac_address
self.interface = interface
self.siteslug = siteslug
self.swroleslug = swroleslug
def postdevice(self):
'''
Метод создаёт новое устройство в Netbox
'''
POSTSW = f"{URLNB}/api/dcim/devices/"
'''
Словарь содержит имя устройства, модель, сайт и роль
Ещё я добавил описание, что бы потом было видно, что
устройство создано скриптом. Его можно убрать,
это не повлияет на работу скрипта
'''
sw_add = {
"name": self.hostname,
"device_type": {
"model": self.model
},
"role": {
"slug": self.swroleslug
},
"site": {
"slug": self.siteslug
},
"description": "Added by script"
}
# делаем POST запрос, передаём в Netbox наш словарь в формате json
response = requests.post(POSTSW, headers=HEADERS, verify=False, json=sw_add)
# Если запрос успешный, то возвращаем id созданного устройства
if response.status_code == 201:
out_dev = response.json()
dev_id = out_dev['id']
return response.status_code, dev_id
else:
return response.status_code, 0
def postint(self):
'''
Метод для создания интерфейса управления
Здесь всё аналогично, интерфейс создаётся,
на только что созданном устройстве
В словаре передаём имя устройства и имя интерфейса,
в примере это vlan4090
'''
POSTINT = f"{URLNB}/api/dcim/interfaces/"
int_add = {
"device": {
"name": self.hostname
},
"name": self.interface,
"type": "virtual",
"enabled": True,
}
response = requests.post(POSTINT, headers=HEADERS, verify=False, json=int_add)
# Если всё хорошо, то возвращаем id интерфейса
if response.status_code == 201:
out_int = response.json()
int_id = out_int['id']
return response.status_code, int_id
else:
return response.status_code, 0
def postip(self, int_id):
'''
Добавляем ip на только что созданный интерфейс.
Вот для этого и нужно было вернуть id интерфейса,
на этот id и будем вешать ip адрес
'''
POSTIP = f"{URLNB}/api/ipam/ip-addresses/"
ip_add = {
"address": self.ipaddress,
"status": "active",
"assigned_object_type": "dcim.interface",
"assigned_object_id": int(int_id)
}
response = requests.post(POSTIP, headers=HEADERS, verify=False, json=ip_add)
return response.status_code
def postmac(self, int_id):
'''
В этом методе создаём мак адрес на интерфейсе
'''
POSTMAC = f"{URLNB}/api/dcim/mac-addresses/"
mac_add = {
"mac_address": self.mac_address,
"assigned_object_type": "dcim.interface",
"assigned_object_id": int(int_id)
}
response = requests.post(POSTMAC, headers=HEADERS, verify=False, json=mac_add)
return response.status_code
def postmainip(self, dev_id):
'''
Здесь делаем ip адрес основным на данном устройвстве.
Метод не обязательный, но ip адресов может быть много
на одном устройстве, основной ip показывается
на первой странице, что удобно, а так же отображается в отдельном поле
если выдёргивать информацию через API.
'''
POSTMAINIP = f"{URLNB}/api/dcim/devices/{dev_id}/"
main_ip = {
"primary_ip4": {
"address": self.ipaddress
}
}
response = requests.patch(POSTMAINIP, headers=HEADERS, verify=False, json=main_ip)
return response.status_code
def postmainmac(self, int_id):
'''
Делаем мак адрес основным, причина та же что и с ip.
Просто удобство для дальнейшей работы с Нетбоксом,
по сути метод не обязательный.
'''
POSTMAINMAC = f"{URLNB}/api/dcim/interfaces/{int_id}/"
main_mac = {
"primary_mac_address": {
"mac_address": self.mac_address
}
}
response = requests.patch(POSTMAINMAC, headers=HEADERS, verify=False, json=main_mac)
return response.status_code
Далее напишем основной код.
Основной код скрипта
'''
Загружаем переменные окружения, что мы прописывали в .env
'''
load_dotenv()
TOKEN_API = os.getenv('API_KEY')
HEADERS = {"Authorization": TOKEN_API}
URLNB = os.getenv('URLNB')
SITE_NAME = os.getenv('SITE_NAME')
IPSCAN = os.getenv('IPSCAN')
SWITCHES_FILE = os.getenv('SWITCHES_FILE')
# Настраиваем логирование
LOGDIR = "./"
LOGNAME = f"log_switch_add_{IPSCAN}.log"
logger = logging.getLogger('SWITCH_ADD')
logger.setLevel(logging.DEBUG)
logfile = logging.handlers.RotatingFileHandler(f'{LOGDIR}{LOGNAME}', mode='w')
logfile.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
logfile.setFormatter(formatter)
logger.addHandler(logfile)
'''
Открываем файл json с нашими коммутаторами, который тоже был
прописан в .еnv
'''
with open(SWITCHES_FILE, 'r') as f:
dt = f.read()
switches_from_file = json.loads(dt)
for sw in switches_from_file:
'''
Перебираем список со словарями
и создаём экземпляр класса со свичём
'''
swadd = NbPostDevice(**sw)
add_code, dev_id = swadd.postdevice()
''' Создаём устройство, если всё Ок, то пишем лог, что всё Ок '''
if add_code == 201:
logger.info(f'Устройство ({sw["hostname"]} - {sw["ipaddress"]}) - было добавлено')
else:
''' Если произошла ошибка, то пишем лог об ошибке и завершаем скрипт'''
logger.error(f'Ошибка добавления ({sw["hostname"]} - {sw["ipaddress"]})')
break
add_int, int_id = swadd.postint()
''' Создаём интерфейс, если всё Ок то пишем лог, что всё Ок '''
if add_int == 201:
logger.info(f'Интерфейс для ({sw["hostname"]} - {sw["ipaddress"]}) - добавлен')
else:
''' Если произошла ошибка, то пишем лог об ошибке и завершаем скрипт '''
logger.error(f'Ошибка добавления интерфейся для ({sw["hostname"]} - {sw["ipaddress"]})')
break
add_ip = swadd.postip(int_id)
''' Создаём ip, если всё Ок то пишем лог, что всё Ок '''
if add_ip == 201:
logger.info(f'IP адресс для ({sw["hostname"]} - {sw["ipaddress"]}) - добавлен')
else:
''' Если произошла ошибка, то пишем лог об ошибке и завершаем скрипт '''
logger.error(f'Ошибка добавления IP ({sw["hostname"]} - {sw["ipaddress"]})')
break
add_mac = swadd.postmac(int_id)
''' Создаём мак, если всё Ок то пишем лог, что всё Ок '''
if add_mac == 201:
logger.info(f'MAC адресс для ({sw["hostname"]} - {sw["mac_address"]}) - добавлен')
else:
''' Если произошла ошибка, то пишем лог об ошибке и завершаем скрипт '''
logger.error(f'Ошибка добавления MAC ({sw["hostname"]} - {sw["mac_address"]})')
break
mainip = swadd.postmainip(dev_id)
''' Делаем ip основным, если всё Ок то пишем лог, что всё Ок '''
if mainip == 200:
logger.info(f'Основной IP адресс для ({sw["hostname"]} - {sw["ipaddress"]}) - добавлен')
else:
''' Если произошла ошибка, то пишем лог об ошибке и завершаем скрипт '''
logger.error(f'Ошибка добавления основного IP ({sw["hostname"]} - {sw["ipaddress"]})')
break
mainmac = swadd.postmainmac(int_id)
''' Делаем мак основным, если всё Ок то пишем лог, что всё Ок '''
if mainmac == 200:
logger.info(f'Основной MAC адресс для ({sw["hostname"]} - {sw["mac_address"]}) - добавлен')
else:
''' Если произошла ошибка, то пишем лог об ошибке и завершаем скрипт '''
logger.error(f'Ошибка добавления основного MAC ({sw["hostname"]} - {sw["mac_address"]})')
break
Здесь небольшое примечание касаемо версии Нетбокса, данный скрипт работает на версии 4.3.3, предыдущая версия которая была у нас 4.1.5, работала с мак адресами по другому, в старой версии мак адрес можно было добавить одновременно с интерфейсом. И можно было добавить таким запросом:
def postint(self):
POSTINT = f"{URLNB}/api/dcim/interfaces/"
int_add = {
"device": {
"name": self.hostname
},
"name": self.interface,
"type": "virtual",
"enabled": True,
"mac_address": self.mac_address
}
response = requests.post(POSTINT, headers=HEADERS, verify=False, json=int_add)
В новой версии такой запрос отрабатывает без ошибок, но мак не добавляется, поэтому нужно создавать мак и биндить его отдельно. Не знаю в какой версии произошли изменения, нужно тестить. В репозитории я оставлю оба скрипта.
Запускаем скрипт и идём смотреть логи, не зря же пол скрипта занимают именно они :)

Всё успешно, ошибок нет. В случае ошибки - работа скрипта завершается. Сделал так специально, что бы не удалять потом сотню устройств которые были добавлены криво или не полностью.
Далее идём в Нетбокс.

Вот они, наши тестовые коммутаторы.
Открываем любой из них.


Все нужные данные добавились.
Смотрим добавился ли мак.

Мак тоже есть.
Вот таким способом можно добавить пачку свичей.
Сбор данных
Теперь возникает вопрос. А где их взять? Коммутаторы.
Какие то коммутаторы есть в заббиксе, какие то в табличке excel, какие то в голове у инженеров. Но полной картины нет нигде.
Тут уже универсального ответа на вопрос нету, всё зависит от конкретной организации и топологии сети, и вариантов решения может быть не один десяток.
Я опишу способ, который реализовал лично я. Для своей топологии он показался мне наиболее удобным и информативным.
Поскольку шлюз у меня это сервак с линуксом, то я воспользуюсь утилитой nmap. В связке с питоном конечно же.
Python в данном случае будет красиво оформлять полученные данные, и сохранять в файлик json. Но что мы получим из него? Это найденный ip, и мак адрес. Хотелось бы побольше инфы. Мне повезло, у меня на свичах везде включен SNMP, и по стандартным OIDам можно попробовать собрать больше информации. Поэтому при успешном обнаружении свича будем пробовать получить данные по SNMP.
Возвращаемся в наш файл настроек .env и добавляем следующие строки
SNMP_COM = 'test_community'
INTERFACE = 'vlan4090'
SITE_SLUG = 'test_site'
CHECK_FILE = 'nmap_192.168.0.0.json'
SWROLESLUG = 'access-switch'
Тут заполняем данные для SNMP, пишем интерфейс управления, сайт, название файла и роль устройства, что бы в дальнейшем уже было проще формировать конечный файл.
Скрипт запускающий nmap и snmpwalk
import subprocess
import re
import json
import os
from dotenv import load_dotenv
class SwitchSnmpGet:
'''
Класс для получения данных по SNMP
принимаем ip и snmp community
'''
def __init__(self, ipaddress, snmp_com):
self.ipaddress = ipaddress
self.snmp_com = snmp_com
def getsysdescr(self):
'''
Класс для получения модели коммутатора по SNMP
'''
sysdescroid = '1.3.6.1.2.1.1.1'
parse_snmp = 'STRING: (?P<snmpout>.+)'
sysdescr = 'unknown'
process = subprocess.Popen(['snmpwalk', '-c', self.snmp_com, '-v2c', self.ipaddress, sysdescroid], stdout=subprocess.PIPE)
while True:
output = process.stdout.readline()
if output == b'' and process.poll() is not None:
break
if output:
outsnmp = output.decode('utf-8')
match = re.search(parse_snmp, outsnmp)
if match:
sysdescr = match.group('snmpout')
return sysdescr
def getsysname(self):
'''
Получает hostname через snmp
'''
sysnameoid = '1.3.6.1.2.1.1.5'
parse_snmp = 'STRING: (?P<snmpout>.+)'
sysname = 'unknown'
process = subprocess.Popen(['snmpwalk', '-c', self.snmp_com, '-v2c', self.ipaddress, sysnameoid], stdout=subprocess.PIPE)
while True:
output = process.stdout.readline()
if output == b'' and process.poll() is not None:
break
if output:
outsnmp = output.decode('utf-8')
match = re.search(parse_snmp, outsnmp)
if match:
sysname = match.group('snmpout')
return sysname
'''
Регулярка для определения ip, мака и вендора
в зависимости от дистрибутива linux, возможно придётся редактировать
у меня работало на Debian
'''
parseout = (r'for (?P<ipaddress>\d+.\d+.\d+.\d+)'
r'|MAC Address: (?P<mac>\S+) \((?P<vendor>.+)\)')
# Загружаем переменные окружения с настройками
load_dotenv()
IPSCAN = os.getenv('IPSCAN')
NETMASK = os.getenv('NETMASK')
SNMP_COM = os.getenv('SNMP_COM')
INTERFACE = os.getenv('INTERFACE')
SITE_SLUG = os.getenv('SITE_SLUG')
SWROLESLUG = os.getenv('SWROLESLUG')
NETWORK = IPSCAN + NETMASK
ipaddress = ''
# Командой 'nmap -sP -n' запрускаем сканирование сети, только пинги, без портов
cmd_for_scan = f'nmap -sP -n {NETWORK}'
cmd = cmd_for_scan.split()
process = subprocess.Popen(cmd, stdout=subprocess.PIPE)
result_list = []
while True:
'''
Запускаем сканирование, при удачном обнаружении устройства
опрашиваем его по SNMP и создаём словарь
полученный словарь добавляем в список
'''
output = process.stdout.readline()
if output == b'' and process.poll() is not None:
break
if output:
outlist = output.strip().decode('utf-8')
match = re.search(parseout, outlist)
'''
Здесь я исключил попадание в словарь устройств
с которых nmap не смог определить вендора и не показывал мак
у меня это сервера, их добавлять буду отдельно
'''
if match:
if match.lastgroup == 'ipaddress':
ipaddress = match.group(match.lastgroup)
else:
mac_address = match.group('mac').lower()
vendor = match.group('vendor')
get_model = SwitchSnmpGet(ipaddress, SNMP_COM)
result_dict = {
'ipaddress': ipaddress+NETMASK,
'interface': INTERFACE,
'mac_address': mac_address,
'vendor': vendor,
'model': get_model.getsysdescr(),
'hostname': get_model.getsysname(),
'siteslug': SITE_SLUG,
'swroleslug': SWROLESLUG,
}
result_list.append(result_dict)
# Сохраняем в файл json
with open(f'nmap_{IPSCAN}.json', 'w') as f:
json.dump(result_list, f, sort_keys=True, indent=2)
Запускаем скрипт от рута.
Открываем наш файл nmap_192.168.0.0.json. Именно это имя мы писали в .env.

Не всё прошло гладко. Но большая часть информации всё же есть. С этим файлом уже придётся работать руками. Не все свичи имеют хостнэйм, или имеют, но не корректный, его придётся придумать и написать самому. Не все свичи показали модель, придётся заходить и смотреть самому, здесь и пригодился ключ vendor, которого нету в конечном файле. По нему заранее видно производителя и понятно как на него заходить (возможно это UPS а не свич). Некоторые модели написаны избыточно, например "DGS-3120-24SC Gigabit Ethernet Switch", его приведём к виду как в Нетбоксе "DGS-3120-24SC".
После правки файла нужно как то проверить, какие коммутаторы уже есть в Нетбоксе, и проверить, есть ли вообще такие модели.
Пишем скрипт для проверки.
Проверяем свичи и модели на наличие в Нетбоксе
import os
import json
import requests
import logging
import logging.handlers
from dotenv import load_dotenv
class NbCheckDevice:
'''
Передаём все имеющиеся в словаре ключи
'''
def __init__(self, ipaddress, hostname, model, mac_address, interface, siteslug, swroleslug):
self.ipaddress = ipaddress
self.hostname = hostname
self.model = model
self.mac_address = mac_address
self.interface = interface
self.swroleslug = swroleslug
self.siteslug = siteslug
def getdevice(self):
'''
Делаем запрос в Нетбок по имени хоста
Если такого хоста нет, то делаем запрос по модели
'''
URLSW = f"{URLNB}/api/dcim/devices/?name="
response = requests.get(URLSW+self.hostname, headers=HEADERS, verify=False)
device_list = json.loads(json.dumps(response.json()))
if device_list['count'] > 0:
swmodel = False
return device_list['count'], swmodel
else:
URLMODEL = f'{URLNB}/api/dcim/device-types/?model='
swmodel = False
response = requests.get(URLMODEL+self.model, headers=HEADERS, verify=False)
model_list = json.loads(json.dumps(response.json()))
if model_list['count'] > 0:
for ml in model_list['results']:
swmodel = ml['model']
return 0, swmodel
else:
return 0, swmodel
# загружаем переменные окружения с настройками
load_dotenv()
TOKEN_API = os.getenv('API_KEY')
HEADERS = {"Authorization": TOKEN_API}
URLNB = os.getenv('URLNB')
SWPOST = f'{URLNB}/api/dcim/devices/'
CHECK_FILE = os.getenv('CHECK_FILE')
IPSCAN = os.getenv('IPSCAN')
# Заморачиваемся с логами
LOGDIR = "./"
LOGNAME = f"log_switch_check_{IPSCAN}.log"
logger = logging.getLogger('SWITCH_CHECK')
logger.setLevel(logging.DEBUG)
logfile = logging.handlers.RotatingFileHandler(f'{LOGDIR}{LOGNAME}', mode='w')
logfile.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
logfile.setFormatter(formatter)
logger.addHandler(logfile)
with open(CHECK_FILE, 'r') as f:
'''
Открываем файл который создал предыдущий скрипт
'''
dt = f.read()
switches_from_file = json.loads(dt)
# Данные списки будем наполнять словарями
unknown_devices = [] # Устройства у которых есть ключи со значение unknown
exist_devices = [] # Устройства которые уже добавлены в Нетбокс
none_model = [] # Устройства для которых не создана модель
add_devices = [] # Устройства которые можно добавить в Нетбокс
for l in switches_from_file:
''' Проверяем коммутатор в Нетбоксе и пишем логи '''
_ = l.pop('vendor') # Убираем ключ vendor, больше он нам не нужен
if l['hostname'] == 'unknown' or l['model'] == 'unknown' == 'unknown':
logger.info(f'Коммутатор ({l["ipaddress"]}) нужно будет добавить руками')
unknown_devices.append(l)
else:
device = NbCheckDevice(**l)
device_count, swmodel = device.getdevice()
if device_count > 0:
logger.info(f'Коммутатор ({l["ipaddress"]}) уже есть в НетБоксе')
exist_devices.append(l)
elif swmodel == False:
logger.info(f'Для коммутатора ({l["ipaddress"]}) нужно создать модель')
none_model.append(l)
else:
logger.info(f'Коммутатор ({l["ipaddress"]}, {swmodel}) можно добавить скриптом')
switch_model = {'model': swmodel}
add_devices.append({**l, **switch_model})
# Пишем полученные списки в файлы
with open(f'unknown_{CHECK_FILE}', 'w') as f:
json.dump(unknown_devices, f, sort_keys=True, indent=2)
with open(f'exist_{CHECK_FILE}', 'w') as f:
json.dump(exist_devices, f, sort_keys=True, indent=2)
with open(f'none_model_{CHECK_FILE}', 'w') as f:
json.dump(none_model, f, sort_keys=True, indent=2)
with open(f'add_{CHECK_FILE}', 'w') as f:
json.dump(add_devices, f, sort_keys=True, indent=2)
Смотрим логи выполнения скрипта

Все 4 файлика создались.
Посмотрим для примера файл "none_model_nmap_192.168.0.0.json".

Модели "Layer 2 Management Switch" нет в Нетбоксе, что и не удивительно. На такой коммутатор нужно будет зайти и самому разобраться что это. Потом смотрим файл с кривыми хостнэймами. В идеале файла должно быть 2 или 1, это те которые уже добавлены в Нетбокс, и который нужно добавить. После устранения недочётов в основном файле (nmap_192.168.0.0.json), пробуем запустить проверку ещё раз.
Когда все недочёты устранены - запускаем скрипт добавления устройств в Нетбокс.
Может показаться, что всё слишком заморочено и долго, и много ручной работы. Но на всё у меня ушла неделя. Это при том, что параллельно я занимался и другими задачами. Описание “Added by script” я добавил специально, чтобы по нему понять, какое устройство было добавлено скриптом. У меня получилось 464 устройства.

Думаю, 464 устройства стоило того, чтобы денёк другой поковырять json файлы и по добавлять модели коммутаторов в Нетбокс. Страшно представить сколько бы времени ушло добавлять их вручную.
Ссылка на скрипты в github.
Комментарии (2)
sfinks777
11.07.2025 10:35Тоже работаю сетевым инженером и тоже кручу почти подобным образом Netbox. Только использую библиотеку pynetbox, чтобы не работать напрямую с request. Я реализовал парсинг конфигураций устройств на предмет используемых интерфейсов, VLAN, адресов линковок, дескрипшенов и прочего и кажую ночь заношу по крону данные в Netbox. При этом ломается концепция Netbox как Source of Truth, но в нашей модели эксплуатации это приемлемо.
Система настолько понравилась, что написал даже плагин под него для бесшовной интеграции нашего процесса эксплутации. Django для непрограммиста - та еще головная боль. Было это еще до эпохи LLM, потому пришлось писать самому, благо документация самого Netbox довольно подробна и обширна.
say_TT_plz
делал нечто подобное, но для связки vcenter + netbox, тоже инвентарил гиппервизоры и виртуалки, только на PoSh.
Netbox прикольный, но только нужно понять, что все крутится вокруг id, у каждой сущности он есть, будь то IP или интерфейс. То есть чтобы заинвентарить тачку, нужно её создать, создать интерфейс, создать ip, потом проассоциировать все это друг с другом.
Из плюсов, можно создавать кастомные поля и заполнять их своими данными. Я так хранил id виртуалки, а специальный обработчик(не я делал, там что-то с помощью питона и внутреннего интерпретатора) создавал кнопку, нажимая на которую переходил по ссылке на виртаулку.
Потом виртуалки стали более эфемерными и смысл их инвентарить пропал. Все должно быть в виде кода в гите.