Плагины
Плагин — отдельная программа, которая добавляет хабу свои разделы, команды сценариев и инструменты MCP. Обычно это контейнер, выложенный обычным конвейером: обновляется и удаляется тоже конвейером, а хаб к нему только подключается. Без подключённых плагинов хаб работает как прежде.
Плагины: Technitium DNS — зоны и записи своих доменов на своих серверах имён, делегирование, DNSSEC; Cloudflare DNS — то же для зон у Cloudflare; почта — ящики, алиасы и DKIM docker-mailserver, записи MX/SPF/DKIM через DNS-плагин; LiteLLM — виртуальные ключи к моделям с бюджетом и лимитами, модели, расход; MikroTik — роутеры как хосты плагинов (устройства без агента nkt в общем списке хостов); Yandex Cloud — ВМ, диски и снимки, группы безопасности, бакеты, Cloud DNS для хаба, базы, баланс.
Как это устроено
- Плагин слушает HTTP на 127.0.0.1 своего хоста (в compose —
127.0.0.1:9180:9180): снаружи его не видно. - Хаб ходит к нему через SSH-соединение с хостом (плагин на машине хаба — напрямую). Хост, подключённый только туннелем (без SSH), для плагина не подходит.
- Каждый вызов подписан общим секретом:
NKT_PLUGIN_SECRETиз.envконвейера плагина. Хаб хранит его зашифрованным, в экспорт он уходит вместе с остальными секретами хаба. - Плагин описывает себя манифестом: разделы, операции, команды сценариев, инструменты MCP, что он делает для хаба и какие права у хаба просит.
Подключить
- Выложите плагин конвейером (пример — в его справке). В
.envконвейера —NKT_PLUGIN_SECRET: generate:hex32и, если порт не 9180,NKT_PLUGIN_PORT. - «О системе» → «Плагины» → «Подключить» → «С конвейера», выберите конвейер. Хост, порт и секрет хаб возьмёт из него сам.
- «Проверить» — хаб покажет, что плагин добавит: разделы, команды сценариев, инструменты MCP, что он делает для хаба и какие права просит. Совпадения (слово сценария или инструмент уже есть) — там же.
- «Подключить». В меню хаба появится раздел «Плагины».
«Вручную» — хост, порт и секрет без конвейера (плагин, установленный не через nkt). «Отключить» убирает разделы, команды и инструменты из хаба и отзывает токен плагина; сам плагин и его данные остаются на хосте.
Каталог плагинов
Плагины и примеры конвейеров не встроены в nkt: они живут в examples/ репозитория, а CI собирает из них каталог и выкладывает его рядом с этим сайтом — https://piqab.github.io/nkt/catalog/. Новый плагин или исправление в старом доходят до хаба без выпуска nkt.
«О системе» → «Плагины» → «Каталог»: версия каждого плагина, отметка «тесты на подделках пройдены», где он уже поставлен и есть ли новее, «Справка» (README и скриншоты плагина) и «Установить на хост».


- Подпись. Каталог подписан ключом ed25519; открытый ключ встроен в nkt. Хаб проверяет подпись и SHA-256 каждого файла — при скачивании, при загрузке архива и при чтении своей копии с диска. Не сошлось — каталог не принимается, работает прежняя копия. Проверить подпись можно и без nkt:
openssl pkeyutl -verify -pubin -inkey ключ.pub -rawin -in index.json -sigfile index.json.sig. - Тесты. CI прогоняет тест каждого плагина на подделках (
deploy/test_plugin.py); упавший плагин в каталог не попадает, остальные выходят. - Копия на хабе — в
DataDir/catalog, обновляется раз в 6 часов и кнопкой «Обновить каталог» (заданием). Установка, обновление плагинов и «Выкладки» → «Примеры» работают из копии — и без интернета. - Без интернета: скачайте
https://piqab.github.io/nkt/catalog.tar.gzна машине с интернетом и загрузите кнопкой «Загрузить архив» — проверка та же. - Совместимость. У плагина есть
min_nkt: хабу старше — «Установить» неактивна с пояснением. - Свой каталог («Источник каталога», с диффом и историей): форк репозитория со своим ключом —
openssl genpkey -algorithm ed25519, закрытый ключ в секретNKT_CATALOG_KEYфорка (содержимое файла целиком, со строкамиBEGIN/END; склеенный в одну строку тоже читается), открытый (openssl pkey -pubout) — в поле «Свои открытые ключи»; или свой сервер с тем же устройством (index.json,index.json.sig, файлы). Свой ключ добавляется к встроенному. Настройки источника едут в экспорт хаба.
Автору плагина: папка examples/<имя>/ — deploy/nkt-plugin.json (с version, min_nkt, protocol), deploy/nkt-plugin.py (VERSION — как в JSON), deploy/test_plugin.py, README.md и README.en.md, скриншоты в screens/ru/ и screens/en/. Поменяли файлы плагина — поднимите version, иначе сборка каталога остановится: хабы иначе не узнали бы об обновлении. Пример конвейера — pipeline*.yaml и nkt-example.json (заготовка для «Примеры ▾»).
Свой плагин
Плагин, которого нет в каталоге, — например, написанный по руководству, — администратор загружает в хаб сам: «О системе» → «Плагины» → «Каталог» → «Загрузить свой плагин».

- Архив папки плагина (
.tar.gzили.zip) — какexamples/<имя>/в репозитории:deploy/nkt-plugin.json,deploy/nkt-plugin.py, README,screens/. Тест можно оставить — хаб его не хранит и не запускает. - Проверка хабом — та же статическая, что
nkt-catalog lintу каталога: пакет, версия =VERSIONв коде, тексты ru/en, секретные настройки, запрещённый код (shell=True,os.system,eval, команда строкой). Ещё — пути в архиве (без.., ссылок и абсолютных путей), размеры (файл до 2 МБ, всё до 10 МБ), имя не из каталога, версия выше уже загруженной. На хабе плагин не запускается. - Код целиком (а у новой версии — дифф с прежней). Отметьте «Код просмотрен» и «Сохранить»: плагин будет работать службой на хостах, и отвечает за него тот, кто загрузил. В журнале действий — кто, версия, SHA-256.
- В каталоге хаба плагин — с меткой «свой»: «Установить на хост», «Обновить на хосте» после загрузки новой версии — как у плагинов каталога; манифест хаб проверяет при подключении. «Удалить свой» убирает его из каталога хаба (если стоит на хостах — с подтверждением; установки остаются до удаления с хоста).
Загружает только администратор и только в интерфейсе (не API-токен и не MCP). Свои плагины и история последних 10 версий кода едут в экспорт хаба.
Пример по шагам — url-check (проверка адресов: код ответа, время, срок сертификата, находки, команда urlcheck АДРЕС):
- «Скачать пример» в окне загрузки (
https://piqab.github.io/nkt/samples/url-check.tar.gz) или из репозитория:tar czf url-check.tar.gz -C examples/_samples url-check. - «Загрузить свой плагин» → архив → код → «Код просмотрен» → «Сохранить».
- «Проверка адресов» (свой) → «Установить на хост» → в форме адреса через запятую → задание.
- Раздел плагина «Адреса» и «Находки»; в сценарии после выкладки —
urlcheck https://shop.example.com/health.
Установить на хост (без Docker)
Плагин из каталога можно поставить службой прямо на хост, без контейнера: «О системе» → «Плагины» → «Установить на хост» → плагин, хост и его настройки (форма своя у каждого плагина) → задание в стандартном окне журнала.

Хаб кладёт файл плагина из проверенной копии каталога в
/opt/nkt-plugins/<имя>/, настройки — в/etc/nkt-plugins/<имя>.env(права0600, только root, без истории конфигураций), юнитnkt-plugin-<имя>.service: отдельный пользователь (DynamicUser),ProtectSystem=strict, слушает только127.0.0.1. Если на хосте в/etc/nsswitch.confнет модуляsystemd(пакетlibnss-systemd), временный пользователь systemd ни на какое имя не отображается, иsshвнутри плагина (Windows, connector) падает с «No user exists for uid …». Тогда хаб заводит постоянного системного пользователяnktp-<имя>без входа и домашнего каталога и удаляет его вместе с плагином. Уже поставленный плагин переходит на него по «Обновить на хосте».Каталоги плагина в
/opt/nkt-pluginsагент создаёт и удаляет вне своей песочницы, как пишет конфигурации, —/optдля службы nkt только для чтения.Секреты (токены из формы и секрет подписи, который создаёт сам хаб) не попадают в параметры задания: одноразовое хранилище в памяти хаба, 15 минут.
Нет
python3— задание поставит пакет.Плагин подключается к хабу сам. «Обновить на хосте» — файл плагина версии хаба и перезапуск (настройки остаются); «Удалить с хоста» — служба, файлы и подключение.
«Настройки» — у плагинов без своих настроек (Windows, MikroTik, connector: учётки устройств хранит хаб) этой кнопки нет. Остальные — те же поля, что при установке (токен, адрес, зоны…), без переустановки. Обычные значения хаб читает с хоста; секреты не показываются никому: «задан» и поле «Заменить» (пусто — прежний). «Сверить» — дифф, «Сохранить» — заданием: файл настроек и перезапуск службы; плагин не ответил — прежние настройки возвращаются сами, в журнале задания — последние строки журнала службы. История обычных значений (20 версий, без секретов) — в хабе, версию можно вернуть. У DNS-плагина после сохранения хаб сразу сверяет свои записи. Новая версия плагина добавила обязательное поле — пометка «нужна настройка». У плагина, выложенного конвейером, настройки —
.envконвейера.
Нужен nkt на хосте этой версии или новее (вызов
/api/plugins/nativeагента).Несколько экземпляров одного плагина (два аккаунта Cloudflare, два Technitium) — разные имена (
dns,dns2): по имени — службаnkt-plugin-<имя>, инструменты MCP и действие токена; порт на хосте хаб выбирает свободный. Слово сценария остаётся за первым, у второго —plugin dns2 dns ….Хаб в Docker — на его машину плагин службой не поставить (в контейнере нет systemd): её нет в списке хостов окна установки.
Доступ к Docker (
"docker": trueв описании установки — например, плагину почтового сервера, которым управляют командой в контейнере): служба в группеdocker, это права root на хосте — окно установки предупреждает заранее.После обновления хаба у плагинов, чей файл в каталоге хаба новее, — пометка «есть версия новее» и кнопка «Обновить все» (одним заданием; сами не обновляются).
Где размещать плагины
| Место | Когда |
|---|---|
| На хосте самой службы | плагин управляет локальной службой (Technitium, почтовый сервер, Garage): её API на 127.0.0.1, наружу ничего не открывается |
| На машине хаба | плагин ходит во внешний API (Cloudflare, Proxmox в сети хаба): проще всего; секреты внешней системы — на машине хаба |
| На отдельном хосте «интеграций» | тоже внешние API, но секретам не место рядом с хабом или у хаба нет доступа в нужную сеть |
Плагин недоступен — его разделы показывают ошибку, сверка DNS повторяется сама; остальной хаб работает.
Каталог — описание установки рядом с файлом плагина в репозитории (examples/<пример>/deploy/nkt-plugin.json):
{
"name": "dns",
"title": {"ru": "Technitium DNS", "en": "Technitium DNS"},
"run": "python3",
"file": "nkt-plugin.py",
"port": 9180,
"settings": [
{"env": "DNS_API", "default": "http://127.0.0.1:5380/api", "title": {"ru": "…", "en": "…"}},
{"env": "DNS_API_TOKEN", "secret": true, "required": true, "title": {"ru": "…", "en": "…"}}
]
}Права
| Кто | Что |
|---|---|
| Любой вошедший, токен чтения | разделы плагина и операции «только чтение» |
| Администратор | всё, что умеет плагин |
| Токен по правам | операции, которые меняют, — с действием «плагин ИМЯ» в окне токена |
| Сам плагин | вызовы хаба — токеном по правам только с теми действиями, которые он просил (hub_actions) и которые вы подтвердили при подключении |
Каждое действие плагина (операция, правка документа, команда сценария) — в журнале действий хаба: plugin.op, plugin.doc, plugin.command, подключение — plugin.connect.
Разделы
Раздел — то, что плагин показывает: таблицы, текст, кнопки. Кнопки вызывают операции плагина (с формой и подтверждением), открывают другой раздел («открыть зону») или правят документ — текст (например, файл зоны) в том же окне, что и всё остальное в nkt: дифф перед записью и история версий в хабе. Если документ изменился, пока вы его правили, запись не пройдёт — хаб покажет новый текст.
Сценарии
Команда плагина пишется так:
plugin ИМЯ СЛОВО аргументы…или одним словом, если его не занял другой плагин (dns set …). «Проверить» спрашивает плагин, правильно ли написано; в сухом прогоне плагин только отвечает, что сделал бы. Справка по словам — в разделе плагина и в командах сценариев.
MCP
Операции, которые плагин отметил для агентов, — инструменты <имя>_<операция> (dns_zones, dns_record_set). Токен чтения видит только операции чтения, остальные — с действием «плагин ИМЯ». Отключили плагин — инструментов нет в tools/list.
Протокол (для своих плагинов)
Версия 1. Плагин — HTTP-сервер на любом языке.
Подпись. Каждый запрос хаба несёт заголовки:
X-NKT-Timestamp— время в секундах Unix;X-NKT-Signature— hex HMAC-SHA256 строки<отметка>.<путь>.<тело>секретом (<путь>—/nkt/manifestили/nkt/call, тело GET — пустое);X-NKT-Lang—ruилиen: тексты плагин отдаёт на этом языке.
Отметка старше 5 минут или неверная подпись — ответ 401.
GET /nkt/manifest:
{
"protocol": 1,
"name": "dns",
"title": "Technitium DNS",
"version": "1.0.0",
"description": "…",
"ops": [
{"name": "zones", "title": "Зоны", "read_only": true, "mcp": true,
"description": "List zones", "input": {"type": "object", "properties": {}}},
{"name": "record_set", "mcp": true, "destructive": false,
"input": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}
],
"pages": [{"id": "zones", "title": "Зоны", "op": "zones"}],
"commands": [{"word": "dns", "usage": "dns set ИМЯ ТИП ЗНАЧЕНИЕ [ttl СЕК]"}],
"provides": ["dns"],
"hub_actions": []
}name— латиница, цифры, дефис (2–32); операции и разделы — латиница, цифры,_. Раздел ссылается на операцию «только чтение»;"hidden": true— не кнопкой вверху, а только переходом из другого раздела (openс аргументами).commands— слова сценариев; слова языка (on,fly,deploy…) занять нельзя.provides: ["dns"]— плагин ведёт DNS-записи для хаба (ниже).hub_actions— действия токена по правам (pipeline.deploy,fly.manage…), которые плагин просит у хаба.
POST /nkt/call — {"op": "…", "args": {…}, "user": "admin", "dry_run": false} → {"ok": true, "text": "…", "result": …, "view": …} или {"ok": false, "error": "текст для человека"}.
Вид (view) раздела:
{"type": "table", "title": "Зоны", "note": "…",
"columns": [{"key": "zone", "title": "Зона"}],
"rows": [{"cells": {"zone": "example.com"},
"actions": [{"title": "Записи", "open": "records", "args": {"zone": "example.com"}},
{"title": "Файл зоны", "args": {"zone": "example.com"},
"edit": {"get": "zone_text", "put": "zone_import", "doc": "example.com"}},
{"title": "Удалить", "op": "zone_delete", "args": {"zone": "example.com"},
"confirm": "Удалить зону example.com?", "danger": true}]}],
"actions": [{"title": "Добавить зону", "op": "zone_add",
"fields": [{"name": "zone", "title": "Зона", "required": true}]}]}type — table, text (text) или sections (items — виды подряд). Поля формы: text, textarea, number, select (options), bool, password (скрытый ввод; значение не пишется в журнал действий). Документ: операция get отдаёт result.text, put получает args.text; историю версий ведёт хаб.
Кнопка с dns — записи, которые плагину нужны в DNS (почта: MX, SPF, DKIM): {"title": "…", "dns": {"owner": "domain:example.com", "records": [{"name": "example.com", "type": "MX", "values": ["10 mail.example.com."], "ttl": 3600}]}}. Хаб ставит их через подключённый DNS-плагин (владелец plugin:<имя>:<owner>, типы A, AAAA, CNAME, TXT, MX) — окно «Сверить» и «Применить», права — как у операции, меняющей данные.
Секреты от плагина (новый ключ API):
"reveals": trueу операции и"reveal": {"title", "value", "note"}в ответе — секрет показывается один раз окну браузера с кнопкой «Скопировать». Токену (API, MCP) хаб такую операцию не даёт, если секрет не уходит сразу в.env."store": {"pipeline", "env", "value"}в ответе — хаб сам записывает значение в.envконвейера (с историей версий) и убирает его из ответа. Куда — только то, что было в запросе: аргументыto_pipelineиenv(операция) или словаto-pipeline КОНВЕЙЕР env ИМЯ(команда сценария). Права (администратор или токен с «pipeline.create» в своих пределах) хаб проверяет до вызова плагина.- Значения не попадают ни в журнал действий, ни в журнал задания.
Служебные операции (их нет в ops, хаб вызывает сам):
| Операция | Аргументы | Когда |
|---|---|---|
_connect | name, hub_url, token | подключение (токен — если просили hub_actions); не нужна — ответьте ошибкой со словом _connect |
_check | argv | «Проверить» сценарий с командой плагина: ошибка — текстом |
_command | argv, dry_run у запроса | выполнение команды сценария; text — строки в журнал задания |
_dns_zones | — | provides: dns: {"result": {"zones": ["example.com"]}} |
_dns_sync | owner, records, previous | provides: dns: привести записи владельца (fly:<маршрут>, site:<домен>) к records, убрав из previous то, чего в records нет |
argv — слова команды сценария начиная со слова команды: dns set app.example.com A 203.0.113.10 → ["dns", "set", "app.example.com", "A", "203.0.113.10"].
Для агентов и сценариев
«Плагины» → плагин → «Для агентов»: инструменты MCP плагина ровно такими, какими их получает агент (имя, описание, аргументы, обязательные — со звёздочкой), кому они доступны (чтение — любому токену, что меняет — токену с действием «плагин ИМЯ», что удаляет, что отдаёт секрет только в .env конвейера), и команды сценариев с примерами. «Скачать JSON» — тот же формат, что ответ tools/list MCP, плюс команды: отдать агенту или положить рядом с его инструкциями. Если MCP выключен, окно об этом скажет.

Свой плагин: самое короткое
Полное руководство — Как написать плагин (оно же examples/AGENTS.md для агентов в репозитории), рабочая заготовка с тестом — examples/_template, проверка — go run ./cmd/nkt-catalog lint -dir examples/<имя>.
Python без зависимостей, один раздел и одна команда:
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRET = os.environ["NKT_PLUGIN_SECRET"].encode()
def signed(h, path, body):
ts = h.headers.get("X-NKT-Timestamp", "0")
want = hmac.new(SECRET, f"{ts}.{path}.".encode() + body, hashlib.sha256).hexdigest()
return abs(time.time() - int(ts)) < 300 and hmac.compare_digest(want, h.headers.get("X-NKT-Signature", ""))
MANIFEST = {"protocol": 1, "name": "hello", "title": "Hello", "version": "0.1",
"ops": [{"name": "info", "read_only": True, "mcp": True, "description": "Say hello"}],
"pages": [{"id": "info", "title": "Hello", "op": "info"}],
"commands": [{"word": "hello", "usage": "hello ИМЯ"}]}
class H(BaseHTTPRequestHandler):
def reply(self, obj, code=200):
raw = json.dumps(obj).encode()
self.send_response(code); self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw))); self.end_headers(); self.wfile.write(raw)
def do_GET(self):
if not signed(self, self.path, b""): return self.reply({"error": "signature"}, 401)
self.reply(MANIFEST)
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not signed(self, self.path, body): return self.reply({"error": "signature"}, 401)
req = json.loads(body)
if req["op"] == "info":
return self.reply({"ok": True, "view": {"type": "text", "text": "Hello from the plugin"}})
if req["op"] in ("_check", "_command"):
argv = req["args"]["argv"]
if len(argv) != 2: return self.reply({"ok": False, "error": "hello ИМЯ"})
return self.reply({"ok": True, "text": f"hello, {argv[1]}" + (" (dry run)" if req.get("dry_run") else "")})
self.reply({"ok": False, "error": f"no op {req['op']}"})
ThreadingHTTPServer(("0.0.0.0", 9180), H).serve_forever()В compose — ports: ["127.0.0.1:9180:9180"] и секрет из .env. Полный пример — плагин Technitium DNS в examples/technitium.