Етап 8: реєстрація зонда одноразовим запрошенням
Найбільше вузьке місце до запуску: агент заводився INSERT-ом у базу, а токен вписувався в командний рядок руками. Поставити зонд у клієнта було неможливо. core.agent_enrollments тримає sha256 одноразового токена; сам токен повертається рівно один раз. Видача під FOR UPDATE в одній транзакції: два агенти з однієї скопійованої команди інакше створили б два зонди з одного запрошення. Відповідь на «немає», «згоріло» і «використано» однакова — розрізняти їх означає підказувати тому, хто підбирає токени. Токен зонда їде окремим полем agent_token, а не в certificate: сертифікат відповідає на інше питання й живе за іншим циклом. Агент зберігає посвідчення в /etc/netpulse/agent.json з правами 0600, через тимчасовий файл і перейменування — обрив живлення посеред запису інакше лишив би половину токена. Знайдено живим прогоном: реєстрація не проходила автентифікацію, бо інтерсептор стоїть на всьому сервері, а не на окремому сервісі — мій же коментар стверджував протилежне. І запуск із самим посвідченням падав: validate() вимагав -agent-id, не знаючи про файл. Сторінка зондів: команда встановлення з токеном, відкликання запрошень, керування модулями й лімітами, видалення. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
edc6209c98
commit
ddaae60fa0
25 changed files with 1549 additions and 102 deletions
61
HISTORY.md
61
HISTORY.md
|
|
@ -1917,3 +1917,64 @@ gateway «Час відгуку» (line) → дві лінії, вісь 0
|
||||||
Причину виправлено, але наслідок треба було обмежити — тепер полотно
|
Причину виправлено, але наслідок треба було обмежити — тепер полотно
|
||||||
падає саме, а навігація й решта інтерфейсу лишаються живими.
|
падає саме, а навігація й решта інтерфейсу лишаються живими.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Етап 8. Реєстрація зонда
|
||||||
|
|
||||||
|
Найбільше вузьке місце до запуску: агент заводився `INSERT`-ом у базу, а
|
||||||
|
токен вписувався в командний рядок руками. Поставити зонд у клієнта було
|
||||||
|
неможливо — людина зі стійки не має доступу до бази й не повинна його
|
||||||
|
мати.
|
||||||
|
|
||||||
|
### Одноразове запрошення
|
||||||
|
|
||||||
|
`core.agent_enrollments`: sha256 токена, підказка імені, набір модулів,
|
||||||
|
строк. Токен існує рівно один раз — у відповіді на створення.
|
||||||
|
|
||||||
|
**Уся видача в одній транзакції під `FOR UPDATE`.** Два агенти,
|
||||||
|
стартовані з однієї скопійованої команди, інакше створили б два зонди з
|
||||||
|
одного запрошення. Перевірено живим прогоном: другий отримує
|
||||||
|
`PermissionDenied`.
|
||||||
|
|
||||||
|
**Ім'я підбирається суфіксом, а не відмовою.** Людина, яка ставить
|
||||||
|
п'ятий агент на однакових машинах, не має вигадувати імена — вона хоче,
|
||||||
|
щоб він просто запрацював.
|
||||||
|
|
||||||
|
### Знайдено живим прогоном
|
||||||
|
|
||||||
|
**Реєстрація не проходила автентифікацію.** Мій же коментар стверджував,
|
||||||
|
що окремий gRPC-сервіс сам собою виводить виклик з-під інтерсептора. Це
|
||||||
|
неправда: інтерсептор реєструється на весь сервер. Перший же запуск
|
||||||
|
агента дав `Unauthenticated: немає токена зонда` — на виклику, яким
|
||||||
|
токен і видається. Виняток тепер заданий явно, повним префіксом сервісу.
|
||||||
|
|
||||||
|
**Запуск із самим посвідченням падав.** `validate()` вимагав `-agent-id`,
|
||||||
|
не знаючи про файл посвідчення, тож агент із валідним
|
||||||
|
`/etc/netpulse/agent.json` відмовлявся стартувати. Перевірка переїхала в
|
||||||
|
`main`, де враховані всі три джерела: прапорці, файл і реєстрація.
|
||||||
|
|
||||||
|
### Керування зондом
|
||||||
|
|
||||||
|
Ім'я, модулі й ліміти правляться з картки. Форма каже прямим текстом, що
|
||||||
|
зміни доїдуть при наступному підключенні: мовчазна затримка виглядає як
|
||||||
|
«не зберіглося».
|
||||||
|
|
||||||
|
Ліміти зливаються в наявний `jsonb` через `||`, а не заміняють його: там
|
||||||
|
можуть лежати поля, яких форма не знає, і затирати їх мовчки — найшвидший
|
||||||
|
спосіб зламати те, чого не бачив.
|
||||||
|
|
||||||
|
Видалення зонда лишає хости без зонда, а не видаляє їх: інакше заміна
|
||||||
|
заліза, на якому стояв агент, коштувала б усієї історії.
|
||||||
|
|
||||||
|
### Перевірено наскрізно
|
||||||
|
|
||||||
|
```
|
||||||
|
запрошення POST /agent-enrollments → np_enr_…, модулі [icmp snmp]
|
||||||
|
реєстрація agent -enroll → «зареєстровано як "QA зонд"»
|
||||||
|
посвідчення /tmp/np-agent-id.json, права 0600, токен np_agt_…
|
||||||
|
сесія встановлено одразу після реєстрації
|
||||||
|
повтор той самий токен → PermissionDenied
|
||||||
|
перезапуск лише -identity, без токена → та сама сесія
|
||||||
|
керування PATCH модулі й ліміти → 204, значення застосовані
|
||||||
|
```
|
||||||
|
|
||||||
|
|
|
||||||
13
ROADMAP.md
13
ROADMAP.md
|
|
@ -210,7 +210,16 @@ diff між ревізіями. Зараз `commit_sha` тимчасово мі
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Етап 8. Керування зондом із UI
|
## Етап 8. Керування зондом із UI — ✅ зроблено 2026-08-25
|
||||||
|
|
||||||
|
> **Зроблено:** `EnrollmentService` із одноразовими запрошеннями, файл
|
||||||
|
> посвідчення на агенті, сторінка зондів із командою встановлення,
|
||||||
|
> керування модулями й лімітами, видалення зонда. Перевірено наскрізно.
|
||||||
|
>
|
||||||
|
> **Лишилось:** справжній CA з підписом CSR — контракт це передбачає,
|
||||||
|
> але власний центр сертифікації з ротацією є окремою системою.
|
||||||
|
|
||||||
|
## Етап 8 (початковий план)
|
||||||
|
|
||||||
Транспорт готовий повністю: сервер уже вміє штовхати `ModuleControl` (які модулі
|
Транспорт готовий повністю: сервер уже вміє штовхати `ModuleControl` (які модулі
|
||||||
вмикати), `TaskDelta` (що опитувати), ліміти в `Welcome` (паралельність, розмір
|
вмикати), `TaskDelta` (що опитувати), ліміти в `Welcome` (паралельність, розмір
|
||||||
|
|
@ -299,7 +308,7 @@ Diff* — усе з ТЗ.
|
||||||
2. ~~**Етап 9 (алерти)** — без сповіщень це не моніторинг.~~ ✅
|
2. ~~**Етап 9 (алерти)** — без сповіщень це не моніторинг.~~ ✅
|
||||||
3. **Етап 6 (шаблони)** — знімає потребу дописувати Go під кожен вендор; що
|
3. **Етап 6 (шаблони)** — знімає потребу дописувати Go під кожен вендор; що
|
||||||
раніше, то менше захардкодженого коду доведеться викидати.
|
раніше, то менше захардкодженого коду доведеться викидати.
|
||||||
4. **Етап 8 (керування зондом)** — дешевий і робить онбординг можливим.
|
4. ~~**Етап 8 (керування зондом)** — дешевий і робить онбординг можливим.~~ ✅
|
||||||
5. **Етап 7 (NCM)** — головний аргумент Enterprise-тарифу.
|
5. **Етап 7 (NCM)** — головний аргумент Enterprise-тарифу.
|
||||||
6. **Етап 10 (мобільний)** — після того, як є що показувати.
|
6. **Етап 10 (мобільний)** — після того, як є що показувати.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,14 @@ type Config struct {
|
||||||
|
|
||||||
// Модулі, увімкнені до першої відповіді сервера.
|
// Модулі, увімкнені до першої відповіді сервера.
|
||||||
DefaultModules []string
|
DefaultModules []string
|
||||||
|
|
||||||
|
// Одноразовий токен запрошення. Якщо заданий — зонд спершу
|
||||||
|
// реєструється, зберігає посвідчення й лише потім працює.
|
||||||
|
EnrollToken string
|
||||||
|
// Бажане ім'я зонда при реєстрації.
|
||||||
|
EnrollName string
|
||||||
|
// Де лежить посвідчення між запусками.
|
||||||
|
IdentityPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func envOr(key, def string) string {
|
func envOr(key, def string) string {
|
||||||
|
|
@ -96,6 +104,13 @@ func Parse(args []string) (*Config, error) {
|
||||||
mods := fs.String("modules", envOr("NETPULSE_MODULES", "icmp"),
|
mods := fs.String("modules", envOr("NETPULSE_MODULES", "icmp"),
|
||||||
"модулі, увімкнені до відповіді сервера (через кому)")
|
"модулі, увімкнені до відповіді сервера (через кому)")
|
||||||
|
|
||||||
|
fs.StringVar(&c.EnrollToken, "enroll", envOr("NETPULSE_ENROLL", ""),
|
||||||
|
"одноразовий токен запрошення з UI")
|
||||||
|
fs.StringVar(&c.EnrollName, "name", envOr("NETPULSE_NAME", ""),
|
||||||
|
"бажане ім'я зонда при реєстрації")
|
||||||
|
fs.StringVar(&c.IdentityPath, "identity", envOr("NETPULSE_IDENTITY", DefaultIdentityPath()),
|
||||||
|
"файл посвідчення зонда")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -113,12 +128,13 @@ func (c *Config) validate() error {
|
||||||
if c.Endpoint == "" {
|
if c.Endpoint == "" {
|
||||||
return errors.New("не вказано адресу сервера (-server або NETPULSE_SERVER)")
|
return errors.New("не вказано адресу сервера (-server або NETPULSE_SERVER)")
|
||||||
}
|
}
|
||||||
if c.AgentID == "" {
|
// Ідентичність тут не перевіряємо взагалі.
|
||||||
return errors.New("не вказано -agent-id: зонд має бути зареєстрований через Enroll")
|
//
|
||||||
}
|
// До реєстрації її ще немає, після — вона приходить із файлу
|
||||||
if c.Token == "" {
|
// посвідчення, про який validate не знає. Перевірка живе в main,
|
||||||
return errors.New("не вказано -token (або NETPULSE_TOKEN)")
|
// коли всі три джерела (прапорці, файл, реєстрація) вже враховані:
|
||||||
}
|
// інакше запуск із самим -identity падав із «зонд не
|
||||||
|
// зареєстрований», хоча посвідчення лежало поруч.
|
||||||
if !c.Insecure && (c.CertFile == "" || c.KeyFile == "") {
|
if !c.Insecure && (c.CertFile == "" || c.KeyFile == "") {
|
||||||
return errors.New("потрібні -cert і -key для mTLS (або -insecure для локального стенду)")
|
return errors.New("потрібні -cert і -key для mTLS (або -insecure для локального стенду)")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
139
agent/internal/config/enroll.go
Normal file
139
agent/internal/config/enroll.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Identity — те, що зонд отримав при реєстрації й тримає між запусками.
|
||||||
|
//
|
||||||
|
// Файл, а не змінна оточення: агент переживає перезавантаження машини,
|
||||||
|
// і вимагати від systemd-юніта нести токен означало б залишити його в
|
||||||
|
// журналі при кожному `systemctl show`.
|
||||||
|
type Identity struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
AgentName string `json:"agent_name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
EnrolledAt time.Time `json:"enrolled_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultIdentityPath — де лежить посвідчення зонда.
|
||||||
|
//
|
||||||
|
// /etc/netpulse на Linux, поруч із бінарником на решті: у Windows і
|
||||||
|
// macOS немає одного очевидного місця, а вигадувати його заради
|
||||||
|
// стенду — зайве.
|
||||||
|
func DefaultIdentityPath() string {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
return "/etc/netpulse/agent.json"
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "agent.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(exe), "agent.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadIdentity читає збережене посвідчення.
|
||||||
|
//
|
||||||
|
// Відсутність файлу — не помилка: так виглядає перший запуск, і саме
|
||||||
|
// тоді потрібен -enroll.
|
||||||
|
func LoadIdentity(path string) (*Identity, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var id Identity
|
||||||
|
if err := json.Unmarshal(b, &id); err != nil {
|
||||||
|
return nil, fmt.Errorf("посвідчення %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if id.AgentID == "" || id.Token == "" {
|
||||||
|
return nil, fmt.Errorf("посвідчення %s неповне", path)
|
||||||
|
}
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveIdentity записує посвідчення з правами 0600.
|
||||||
|
//
|
||||||
|
// Через тимчасовий файл і перейменування: обрив живлення посеред
|
||||||
|
// запису інакше лишив би зонд із половиною токена, і полагодити це
|
||||||
|
// можна було б лише новим запрошенням.
|
||||||
|
func SaveIdentity(path string, id *Identity) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(id, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enroll обмінює одноразове запрошення на постійне посвідчення.
|
||||||
|
//
|
||||||
|
// Окреме з'єднання, а не той самий канал, що й Control: реєстрація йде
|
||||||
|
// без токена зонда, і змішувати два різні рівні довіри в одному
|
||||||
|
// з'єднанні означає ускладнити обидва.
|
||||||
|
func Enroll(ctx context.Context, endpoint, token, name string, tlsCfg *tls.Config, build *npv1.AgentBuild) (*Identity, error) {
|
||||||
|
var opt grpc.DialOption
|
||||||
|
if tlsCfg == nil {
|
||||||
|
opt = grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||||
|
} else {
|
||||||
|
opt = grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient(endpoint, opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("зʼєднання з %s: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
res, err := npv1.NewEnrollmentServiceClient(conn).Enroll(ctx, &npv1.EnrollRequest{
|
||||||
|
EnrollmentToken: token,
|
||||||
|
Hostname: host,
|
||||||
|
RequestedName: name,
|
||||||
|
Build: build,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("реєстрація: %w", err)
|
||||||
|
}
|
||||||
|
if res.GetAgentToken() == "" {
|
||||||
|
return nil, errors.New("сервер не видав токен зонда")
|
||||||
|
}
|
||||||
|
|
||||||
|
ep := res.GetControlEndpoint()
|
||||||
|
if ep == "" {
|
||||||
|
ep = endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Identity{
|
||||||
|
AgentID: res.GetAgentId(),
|
||||||
|
AgentName: res.GetAgentName(),
|
||||||
|
Token: res.GetAgentToken(),
|
||||||
|
Endpoint: ep,
|
||||||
|
EnrolledAt: time.Now().UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
52
db/migrations/0023_agent_enrollment.sql
Normal file
52
db/migrations/0023_agent_enrollment.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- NetPulse :: 0023_agent_enrollment.sql
|
||||||
|
-- Реєстрація зонда одноразовим токеном.
|
||||||
|
--
|
||||||
|
-- Досі зонд заводився INSERT-ом у БД, а токен вписувався в командний
|
||||||
|
-- рядок руками. На стенді це прийнятно; у клієнта — ні: людина, яка
|
||||||
|
-- ставить агента, не має доступу до бази й не повинна його мати.
|
||||||
|
--
|
||||||
|
-- Схема така: адміністратор створює запрошення в UI, копіює команду
|
||||||
|
-- встановлення, агент обмінює одноразовий токен на постійний. Токен
|
||||||
|
-- згорає при першому використанні.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE core.agent_enrollments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||||
|
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||||
|
-- sha256 токена. Сам токен існує рівно один раз — у відповіді на
|
||||||
|
-- створення. Зберігати його означало б тримати в базі чинний доступ.
|
||||||
|
token_hash bytea NOT NULL UNIQUE,
|
||||||
|
-- Перші символи для впізнавання в списку: «np_enr_a1b2…».
|
||||||
|
prefix text NOT NULL,
|
||||||
|
-- Підказка імені майбутнього зонда. Саме підказка: остаточне ім'я
|
||||||
|
-- може прийти від самого агента або отримати суфікс при збігу.
|
||||||
|
name_hint text,
|
||||||
|
site_id uuid REFERENCES inv.sites(id) ON DELETE SET NULL,
|
||||||
|
-- Модулі, з якими зонд стартує. Далі керуються з UI.
|
||||||
|
modules core.slug[] NOT NULL DEFAULT '{icmp}',
|
||||||
|
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
used_at timestamptz,
|
||||||
|
used_by uuid REFERENCES core.agents(id) ON DELETE SET NULL,
|
||||||
|
created_by uuid REFERENCES core.users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX agent_enrollments_tenant_idx ON core.agent_enrollments (tenant_id)
|
||||||
|
WHERE used_at IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE core.agent_enrollments ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE core.agent_enrollments FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation ON core.agent_enrollments
|
||||||
|
USING (tenant_id = core.current_tenant())
|
||||||
|
WITH CHECK (tenant_id = core.current_tenant());
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.agent_enrollments
|
||||||
|
TO netpulse_app, netpulse_worker;
|
||||||
|
|
||||||
|
-- Звідки взявся зонд: заведений руками чи прийшов сам по запрошенню.
|
||||||
|
-- Потрібно журналу аудиту й тому, хто через рік питає «а це що за
|
||||||
|
-- агент і хто його ставив».
|
||||||
|
ALTER TABLE core.agents
|
||||||
|
ADD COLUMN enrolled_at timestamptz,
|
||||||
|
ADD COLUMN enrolled_by uuid REFERENCES core.users(id) ON DELETE SET NULL;
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.12
|
// protoc-gen-go v1.36.12
|
||||||
// protoc v3.21.12
|
// protoc (unknown)
|
||||||
// source: netpulse/v1/agent.proto
|
// source: netpulse/v1/agent.proto
|
||||||
|
|
||||||
package netpulsev1
|
package netpulsev1
|
||||||
|
|
@ -320,8 +320,16 @@ type EnrollResponse struct {
|
||||||
// Куди підключатись далі (може відрізнятись від адреси реєстрації:
|
// Куди підключатись далі (може відрізнятись від адреси реєстрації:
|
||||||
// балансувальник, регіональний шлюз).
|
// балансувальник, регіональний шлюз).
|
||||||
ControlEndpoint string `protobuf:"bytes,6,opt,name=control_endpoint,json=controlEndpoint,proto3" json:"control_endpoint,omitempty"`
|
ControlEndpoint string `protobuf:"bytes,6,opt,name=control_endpoint,json=controlEndpoint,proto3" json:"control_endpoint,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
// Постійний токен зонда: визначає, ЯКИЙ це зонд. Видається рівно
|
||||||
sizeCache protoimpl.SizeCache
|
// один раз — далі агент зберігає його сам.
|
||||||
|
//
|
||||||
|
// Окреме поле, а не certificate: сертифікат відповідає на інше
|
||||||
|
// питання («чи має право говорити з сервером») і живе за іншим
|
||||||
|
// життєвим циклом. Складати два різні секрети в одне поле означає
|
||||||
|
// зафіксувати проміжний етап у протоколі назавжди.
|
||||||
|
AgentToken string `protobuf:"bytes,7,opt,name=agent_token,json=agentToken,proto3" json:"agent_token,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *EnrollResponse) Reset() {
|
func (x *EnrollResponse) Reset() {
|
||||||
|
|
@ -396,6 +404,13 @@ func (x *EnrollResponse) GetControlEndpoint() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *EnrollResponse) GetAgentToken() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.AgentToken
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type ControlUp struct {
|
type ControlUp struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
// Монотонний номер повідомлення в сесії — для трасування.
|
// Монотонний номер повідомлення в сесії — для трасування.
|
||||||
|
|
@ -2212,7 +2227,7 @@ const file_netpulse_v1_agent_proto_rawDesc = "" +
|
||||||
"\x03csr\x18\x02 \x01(\fR\x03csr\x12\x1a\n" +
|
"\x03csr\x18\x02 \x01(\fR\x03csr\x12\x1a\n" +
|
||||||
"\bhostname\x18\x03 \x01(\tR\bhostname\x12-\n" +
|
"\bhostname\x18\x03 \x01(\tR\bhostname\x12-\n" +
|
||||||
"\x05build\x18\x04 \x01(\v2\x17.netpulse.v1.AgentBuildR\x05build\x12%\n" +
|
"\x05build\x18\x04 \x01(\v2\x17.netpulse.v1.AgentBuildR\x05build\x12%\n" +
|
||||||
"\x0erequested_name\x18\x05 \x01(\tR\rrequestedName\"\x84\x02\n" +
|
"\x0erequested_name\x18\x05 \x01(\tR\rrequestedName\"\xa5\x02\n" +
|
||||||
"\x0eEnrollResponse\x12\x19\n" +
|
"\x0eEnrollResponse\x12\x19\n" +
|
||||||
"\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1d\n" +
|
"\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
|
|
@ -2220,7 +2235,9 @@ const file_netpulse_v1_agent_proto_rawDesc = "" +
|
||||||
"\vcertificate\x18\x03 \x01(\fR\vcertificate\x12\x19\n" +
|
"\vcertificate\x18\x03 \x01(\fR\vcertificate\x12\x19\n" +
|
||||||
"\bca_chain\x18\x04 \x01(\fR\acaChain\x12P\n" +
|
"\bca_chain\x18\x04 \x01(\fR\acaChain\x12P\n" +
|
||||||
"\x16certificate_expires_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\x14certificateExpiresAt\x12)\n" +
|
"\x16certificate_expires_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\x14certificateExpiresAt\x12)\n" +
|
||||||
"\x10control_endpoint\x18\x06 \x01(\tR\x0fcontrolEndpoint\"\x93\x04\n" +
|
"\x10control_endpoint\x18\x06 \x01(\tR\x0fcontrolEndpoint\x12\x1f\n" +
|
||||||
|
"\vagent_token\x18\a \x01(\tR\n" +
|
||||||
|
"agentToken\"\x93\x04\n" +
|
||||||
"\tControlUp\x12\x10\n" +
|
"\tControlUp\x12\x10\n" +
|
||||||
"\x03seq\x18\x01 \x01(\x04R\x03seq\x12*\n" +
|
"\x03seq\x18\x01 \x01(\x04R\x03seq\x12*\n" +
|
||||||
"\x05hello\x18\x02 \x01(\v2\x12.netpulse.v1.HelloH\x00R\x05hello\x126\n" +
|
"\x05hello\x18\x02 \x01(\v2\x12.netpulse.v1.HelloH\x00R\x05hello\x126\n" +
|
||||||
|
|
@ -2409,7 +2426,9 @@ const file_netpulse_v1_agent_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"StreamLogs\x12\x15.netpulse.v1.LogBatch\x1a\x13.netpulse.v1.LogAck(\x010\x01\x12J\n" +
|
"StreamLogs\x12\x15.netpulse.v1.LogBatch\x1a\x13.netpulse.v1.LogAck(\x010\x01\x12J\n" +
|
||||||
"\x0fReportDiscovery\x12\x1c.netpulse.v1.DiscoveryReport\x1a\x19.netpulse.v1.DiscoveryAck\x12G\n" +
|
"\x0fReportDiscovery\x12\x1c.netpulse.v1.DiscoveryReport\x1a\x19.netpulse.v1.DiscoveryAck\x12G\n" +
|
||||||
"\fUploadConfig\x12\x19.netpulse.v1.ConfigUpload\x1a\x1a.netpulse.v1.ConfigReceipt(\x01B<Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1b\x06proto3"
|
"\fUploadConfig\x12\x19.netpulse.v1.ConfigUpload\x1a\x1a.netpulse.v1.ConfigReceipt(\x01B\xa6\x01\n" +
|
||||||
|
"\x0fcom.netpulse.v1B\n" +
|
||||||
|
"AgentProtoP\x01Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1\xa2\x02\x03NXX\xaa\x02\vNetpulse.V1\xca\x02\vNetpulse\\V1\xe2\x02\x17Netpulse\\V1\\GPBMetadata\xea\x02\fNetpulse::V1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_netpulse_v1_agent_proto_rawDescOnce sync.Once
|
file_netpulse_v1_agent_proto_rawDescOnce sync.Once
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc v3.21.12
|
// - protoc (unknown)
|
||||||
// source: netpulse/v1/agent.proto
|
// source: netpulse/v1/agent.proto
|
||||||
|
|
||||||
package netpulsev1
|
package netpulsev1
|
||||||
|
|
@ -59,14 +59,13 @@ func (c *enrollmentServiceClient) Enroll(ctx context.Context, in *EnrollRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnrollmentServiceServer is the server API for EnrollmentService service.
|
// EnrollmentServiceServer is the server API for EnrollmentService service.
|
||||||
// All implementations must embed UnimplementedEnrollmentServiceServer
|
// All implementations should embed UnimplementedEnrollmentServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
type EnrollmentServiceServer interface {
|
type EnrollmentServiceServer interface {
|
||||||
Enroll(context.Context, *EnrollRequest) (*EnrollResponse, error)
|
Enroll(context.Context, *EnrollRequest) (*EnrollResponse, error)
|
||||||
mustEmbedUnimplementedEnrollmentServiceServer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnimplementedEnrollmentServiceServer must be embedded to have
|
// UnimplementedEnrollmentServiceServer should be embedded to have
|
||||||
// forward compatible implementations.
|
// forward compatible implementations.
|
||||||
//
|
//
|
||||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
|
@ -76,8 +75,7 @@ type UnimplementedEnrollmentServiceServer struct{}
|
||||||
func (UnimplementedEnrollmentServiceServer) Enroll(context.Context, *EnrollRequest) (*EnrollResponse, error) {
|
func (UnimplementedEnrollmentServiceServer) Enroll(context.Context, *EnrollRequest) (*EnrollResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method Enroll not implemented")
|
return nil, status.Error(codes.Unimplemented, "method Enroll not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedEnrollmentServiceServer) mustEmbedUnimplementedEnrollmentServiceServer() {}
|
func (UnimplementedEnrollmentServiceServer) testEmbeddedByValue() {}
|
||||||
func (UnimplementedEnrollmentServiceServer) testEmbeddedByValue() {}
|
|
||||||
|
|
||||||
// UnsafeEnrollmentServiceServer may be embedded to opt out of forward compatibility for this service.
|
// UnsafeEnrollmentServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||||
// Use of this interface is not recommended, as added methods to EnrollmentServiceServer will
|
// Use of this interface is not recommended, as added methods to EnrollmentServiceServer will
|
||||||
|
|
@ -230,7 +228,7 @@ func (c *agentServiceClient) UploadConfig(ctx context.Context, opts ...grpc.Call
|
||||||
type AgentService_UploadConfigClient = grpc.ClientStreamingClient[ConfigUpload, ConfigReceipt]
|
type AgentService_UploadConfigClient = grpc.ClientStreamingClient[ConfigUpload, ConfigReceipt]
|
||||||
|
|
||||||
// AgentServiceServer is the server API for AgentService service.
|
// AgentServiceServer is the server API for AgentService service.
|
||||||
// All implementations must embed UnimplementedAgentServiceServer
|
// All implementations should embed UnimplementedAgentServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
type AgentServiceServer interface {
|
type AgentServiceServer interface {
|
||||||
// Довгоживучий двонаправлений канал керування. Одна сесія = одне
|
// Довгоживучий двонаправлений канал керування. Одна сесія = одне
|
||||||
|
|
@ -247,10 +245,9 @@ type AgentServiceServer interface {
|
||||||
ReportDiscovery(context.Context, *DiscoveryReport) (*DiscoveryAck, error)
|
ReportDiscovery(context.Context, *DiscoveryReport) (*DiscoveryAck, error)
|
||||||
// Вивантаження зібраного конфігу (чанками).
|
// Вивантаження зібраного конфігу (чанками).
|
||||||
UploadConfig(grpc.ClientStreamingServer[ConfigUpload, ConfigReceipt]) error
|
UploadConfig(grpc.ClientStreamingServer[ConfigUpload, ConfigReceipt]) error
|
||||||
mustEmbedUnimplementedAgentServiceServer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnimplementedAgentServiceServer must be embedded to have
|
// UnimplementedAgentServiceServer should be embedded to have
|
||||||
// forward compatible implementations.
|
// forward compatible implementations.
|
||||||
//
|
//
|
||||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
|
@ -272,8 +269,7 @@ func (UnimplementedAgentServiceServer) ReportDiscovery(context.Context, *Discove
|
||||||
func (UnimplementedAgentServiceServer) UploadConfig(grpc.ClientStreamingServer[ConfigUpload, ConfigReceipt]) error {
|
func (UnimplementedAgentServiceServer) UploadConfig(grpc.ClientStreamingServer[ConfigUpload, ConfigReceipt]) error {
|
||||||
return status.Error(codes.Unimplemented, "method UploadConfig not implemented")
|
return status.Error(codes.Unimplemented, "method UploadConfig not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedAgentServiceServer) mustEmbedUnimplementedAgentServiceServer() {}
|
func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
|
||||||
func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
|
|
||||||
|
|
||||||
// UnsafeAgentServiceServer may be embedded to opt out of forward compatibility for this service.
|
// UnsafeAgentServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||||
// Use of this interface is not recommended, as added methods to AgentServiceServer will
|
// Use of this interface is not recommended, as added methods to AgentServiceServer will
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.12
|
// protoc-gen-go v1.36.12
|
||||||
// protoc v3.21.12
|
// protoc (unknown)
|
||||||
// source: netpulse/v1/common.proto
|
// source: netpulse/v1/common.proto
|
||||||
|
|
||||||
package netpulsev1
|
package netpulsev1
|
||||||
|
|
@ -994,7 +994,8 @@ const file_netpulse_v1_common_proto_rawDesc = "" +
|
||||||
"\x13DISCOVERY_PROTO_FDB\x10\x04\x12\x17\n" +
|
"\x13DISCOVERY_PROTO_FDB\x10\x04\x12\x17\n" +
|
||||||
"\x13DISCOVERY_PROTO_STP\x10\x05\x12\x1b\n" +
|
"\x13DISCOVERY_PROTO_STP\x10\x05\x12\x1b\n" +
|
||||||
"\x17DISCOVERY_PROTO_ROUTING\x10\x06\x12\x1d\n" +
|
"\x17DISCOVERY_PROTO_ROUTING\x10\x06\x12\x1d\n" +
|
||||||
"\x19DISCOVERY_PROTO_SNMP_TOPO\x10\aB<Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1b\x06proto3"
|
"\x19DISCOVERY_PROTO_SNMP_TOPO\x10\aB\xa7\x01\n" +
|
||||||
|
"\x0fcom.netpulse.v1B\vCommonProtoP\x01Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1\xa2\x02\x03NXX\xaa\x02\vNetpulse.V1\xca\x02\vNetpulse\\V1\xe2\x02\x17Netpulse\\V1\\GPBMetadata\xea\x02\fNetpulse::V1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_netpulse_v1_common_proto_rawDescOnce sync.Once
|
file_netpulse_v1_common_proto_rawDescOnce sync.Once
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.12
|
// protoc-gen-go v1.36.12
|
||||||
// protoc v3.21.12
|
// protoc (unknown)
|
||||||
// source: netpulse/v1/discovery.proto
|
// source: netpulse/v1/discovery.proto
|
||||||
|
|
||||||
package netpulsev1
|
package netpulsev1
|
||||||
|
|
@ -729,7 +729,8 @@ const file_netpulse_v1_discovery_proto_rawDesc = "" +
|
||||||
"\x12neighbors_resolved\x18\x02 \x01(\rR\x11neighborsResolved\x12#\n" +
|
"\x12neighbors_resolved\x18\x02 \x01(\rR\x11neighborsResolved\x12#\n" +
|
||||||
"\rlinks_created\x18\x03 \x01(\rR\flinksCreated\x12'\n" +
|
"\rlinks_created\x18\x03 \x01(\rR\flinksCreated\x12'\n" +
|
||||||
"\x0fdevices_created\x18\x04 \x01(\rR\x0edevicesCreated\x12(\n" +
|
"\x0fdevices_created\x18\x04 \x01(\rR\x0edevicesCreated\x12(\n" +
|
||||||
"\x05error\x18\x05 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB<Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1b\x06proto3"
|
"\x05error\x18\x05 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB\xaa\x01\n" +
|
||||||
|
"\x0fcom.netpulse.v1B\x0eDiscoveryProtoP\x01Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1\xa2\x02\x03NXX\xaa\x02\vNetpulse.V1\xca\x02\vNetpulse\\V1\xe2\x02\x17Netpulse\\V1\\GPBMetadata\xea\x02\fNetpulse::V1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_netpulse_v1_discovery_proto_rawDescOnce sync.Once
|
file_netpulse_v1_discovery_proto_rawDescOnce sync.Once
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.12
|
// protoc-gen-go v1.36.12
|
||||||
// protoc v3.21.12
|
// protoc (unknown)
|
||||||
// source: netpulse/v1/logs.proto
|
// source: netpulse/v1/logs.proto
|
||||||
|
|
||||||
package netpulsev1
|
package netpulsev1
|
||||||
|
|
@ -473,7 +473,8 @@ const file_netpulse_v1_logs_proto_rawDesc = "" +
|
||||||
"\x16acked_through_batch_id\x18\x01 \x01(\x04R\x13ackedThroughBatchId\x12!\n" +
|
"\x16acked_through_batch_id\x18\x01 \x01(\x04R\x13ackedThroughBatchId\x12!\n" +
|
||||||
"\fmin_severity\x18\x02 \x01(\rR\vminSeverity\x121\n" +
|
"\fmin_severity\x18\x02 \x01(\rR\vminSeverity\x121\n" +
|
||||||
"\x15rate_limit_per_source\x18\x03 \x01(\rR\x12rateLimitPerSource\x12(\n" +
|
"\x15rate_limit_per_source\x18\x03 \x01(\rR\x12rateLimitPerSource\x12(\n" +
|
||||||
"\x05error\x18\x04 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB<Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1b\x06proto3"
|
"\x05error\x18\x04 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB\xa5\x01\n" +
|
||||||
|
"\x0fcom.netpulse.v1B\tLogsProtoP\x01Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1\xa2\x02\x03NXX\xaa\x02\vNetpulse.V1\xca\x02\vNetpulse\\V1\xe2\x02\x17Netpulse\\V1\\GPBMetadata\xea\x02\fNetpulse::V1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_netpulse_v1_logs_proto_rawDescOnce sync.Once
|
file_netpulse_v1_logs_proto_rawDescOnce sync.Once
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.12
|
// protoc-gen-go v1.36.12
|
||||||
// protoc v3.21.12
|
// protoc (unknown)
|
||||||
// source: netpulse/v1/ncm.proto
|
// source: netpulse/v1/ncm.proto
|
||||||
|
|
||||||
package netpulsev1
|
package netpulsev1
|
||||||
|
|
@ -1003,7 +1003,8 @@ const file_netpulse_v1_ncm_proto_rawDesc = "" +
|
||||||
"\x06output\x18\x03 \x01(\tR\x06output\x12\x18\n" +
|
"\x06output\x18\x03 \x01(\tR\x06output\x12\x18\n" +
|
||||||
"\asuccess\x18\x04 \x01(\bR\asuccess\x12\x1d\n" +
|
"\asuccess\x18\x04 \x01(\bR\asuccess\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"error_line\x18\x05 \x01(\tR\terrorLineB<Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1b\x06proto3"
|
"error_line\x18\x05 \x01(\tR\terrorLineB\xa4\x01\n" +
|
||||||
|
"\x0fcom.netpulse.v1B\bNcmProtoP\x01Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1\xa2\x02\x03NXX\xaa\x02\vNetpulse.V1\xca\x02\vNetpulse\\V1\xe2\x02\x17Netpulse\\V1\\GPBMetadata\xea\x02\fNetpulse::V1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_netpulse_v1_ncm_proto_rawDescOnce sync.Once
|
file_netpulse_v1_ncm_proto_rawDescOnce sync.Once
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.12
|
// protoc-gen-go v1.36.12
|
||||||
// protoc v3.21.12
|
// protoc (unknown)
|
||||||
// source: netpulse/v1/telemetry.proto
|
// source: netpulse/v1/telemetry.proto
|
||||||
|
|
||||||
package netpulsev1
|
package netpulsev1
|
||||||
|
|
@ -1025,7 +1025,8 @@ const file_netpulse_v1_telemetry_proto_rawDesc = "" +
|
||||||
"\vretry_after\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\n" +
|
"\vretry_after\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\n" +
|
||||||
"retryAfter\x12,\n" +
|
"retryAfter\x12,\n" +
|
||||||
"\x12reset_series_table\x18\x05 \x01(\bR\x10resetSeriesTable\x12(\n" +
|
"\x12reset_series_table\x18\x05 \x01(\bR\x10resetSeriesTable\x12(\n" +
|
||||||
"\x05error\x18\x06 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB<Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1b\x06proto3"
|
"\x05error\x18\x06 \x01(\v2\x12.netpulse.v1.ErrorR\x05errorB\xaa\x01\n" +
|
||||||
|
"\x0fcom.netpulse.v1B\x0eTelemetryProtoP\x01Z:github.com/netpulse/netpulse/gen/go/netpulse/v1;netpulsev1\xa2\x02\x03NXX\xaa\x02\vNetpulse.V1\xca\x02\vNetpulse\\V1\xe2\x02\x17Netpulse\\V1\\GPBMetadata\xea\x02\fNetpulse::V1b\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_netpulse_v1_telemetry_proto_rawDescOnce sync.Once
|
file_netpulse_v1_telemetry_proto_rawDescOnce sync.Once
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,15 @@ message EnrollResponse {
|
||||||
// Куди підключатись далі (може відрізнятись від адреси реєстрації:
|
// Куди підключатись далі (може відрізнятись від адреси реєстрації:
|
||||||
// балансувальник, регіональний шлюз).
|
// балансувальник, регіональний шлюз).
|
||||||
string control_endpoint = 6;
|
string control_endpoint = 6;
|
||||||
|
|
||||||
|
// Постійний токен зонда: визначає, ЯКИЙ це зонд. Видається рівно
|
||||||
|
// один раз — далі агент зберігає його сам.
|
||||||
|
//
|
||||||
|
// Окреме поле, а не certificate: сертифікат відповідає на інше
|
||||||
|
// питання («чи має право говорити з сервером») і живе за іншим
|
||||||
|
// життєвим циклом. Складати два різні секрети в одне поле означає
|
||||||
|
// зафіксувати проміжний етап у протоколі назавжди.
|
||||||
|
string agent_token = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -602,6 +602,65 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
|
||||||
задачі й падав на кожній із «немає SNMP-креденшелів» — пачка доступів
|
задачі й падав на кожній із «немає SNMP-креденшелів» — пачка доступів
|
||||||
видається на `Hello`, а тоді цього хоста в ній ще не було.
|
видається на `Hello`, а тоді цього хоста в ній ще не було.
|
||||||
|
|
||||||
|
### Реєстрація зонда
|
||||||
|
|
||||||
|
Досі зонд заводився `INSERT`-ом у БД, а токен вписувався в командний
|
||||||
|
рядок руками. На стенді це прийнятно; у клієнта — ні: людина, яка
|
||||||
|
ставить агента, не має доступу до бази й не повинна його мати.
|
||||||
|
|
||||||
|
| Метод | Шлях | Призначення |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `GET` | `/api/v1/agent-enrollments` | запрошення тенанта |
|
||||||
|
| `POST` | `/api/v1/agent-enrollments` | видати одноразовий токен |
|
||||||
|
| `DELETE` | `/api/v1/agent-enrollments/{id}` | відкликати невикористане |
|
||||||
|
| `PATCH` | `/api/v1/agents/{id}` | імʼя, модулі, ліміти |
|
||||||
|
| `DELETE` | `/api/v1/agents/{id}` | видалити зонд |
|
||||||
|
|
||||||
|
**Токен повертається рівно один раз** — у відповіді на створення. У базі
|
||||||
|
лежить лише його sha256. Якщо людина закрила вікно, не скопіювавши
|
||||||
|
команду, простіше створити нове запрошення, ніж тримати чинний доступ у
|
||||||
|
базі заради такого випадку.
|
||||||
|
|
||||||
|
Строк життя — доба, стеля тиждень. Запрошення, що живе місяць, перестає
|
||||||
|
бути запрошенням і стає постійним ключем у чиємусь чаті.
|
||||||
|
|
||||||
|
#### Обмін
|
||||||
|
|
||||||
|
`EnrollmentService.Enroll` (gRPC) міняє одноразовий токен на постійний.
|
||||||
|
Це **єдиний виклик без токена зонда**, і саме тому він виведений з-під
|
||||||
|
інтерсептора автентифікації за повним префіксом сервісу — не за
|
||||||
|
підрядком у назві методу, який колись відкриє ще щось.
|
||||||
|
|
||||||
|
Уся операція в одній транзакції під `FOR UPDATE`: два агенти, стартовані
|
||||||
|
з однієї скопійованої команди, інакше створили б два зонди з одного
|
||||||
|
запрошення. Перевірено — другий отримує `PermissionDenied`.
|
||||||
|
|
||||||
|
Відповідь на «немає», «згоріло» і «вже використано» однакова: розрізняти
|
||||||
|
їх означало б підказувати тому, хто підбирає токени, наскільки він
|
||||||
|
близько.
|
||||||
|
|
||||||
|
Токен зонда їде окремим полем `agent_token`, а не в `certificate`.
|
||||||
|
Сертифікат відповідає на інше питання — «чи має право говорити з
|
||||||
|
сервером» — і живе за іншим життєвим циклом; складати два різні секрети
|
||||||
|
в одне поле означає зафіксувати проміжний етап у протоколі назавжди.
|
||||||
|
mTLS у продукті є (сервер приймає `-client-ca`), але власний CA з
|
||||||
|
ротацією — окрема система, і вдавати, що вона вже працює, тут не варто.
|
||||||
|
|
||||||
|
#### Що робить агент
|
||||||
|
|
||||||
|
```
|
||||||
|
netpulse-agent -server netpulse.example.com:9443 -enroll np_enr_…
|
||||||
|
```
|
||||||
|
|
||||||
|
Отримане посвідчення лягає у `/etc/netpulse/agent.json` з правами 0600 —
|
||||||
|
через тимчасовий файл і перейменування, щоб обрив живлення посеред
|
||||||
|
запису не лишив зонд із половиною токена. Далі агент запускається без
|
||||||
|
жодних параметрів автентифікації: `-server` і файл посвідчення.
|
||||||
|
|
||||||
|
Якщо запис не вдався, повідомлення несе сам токен: інакше довелося б
|
||||||
|
створювати нове запрошення лише через те, що каталог виявився
|
||||||
|
недоступним.
|
||||||
|
|
||||||
### Історія метрик
|
### Історія метрик
|
||||||
|
|
||||||
| Метод | Шлях | Призначення |
|
| Метод | Шлях | Призначення |
|
||||||
|
|
|
||||||
67
server/internal/grpcapi/enroll.go
Normal file
67
server/internal/grpcapi/enroll.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package grpcapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
|
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnrollService реалізує npv1.EnrollmentServiceServer.
|
||||||
|
//
|
||||||
|
// Окремий тип, а не метод на Service: Enroll — єдиний виклик, який іде
|
||||||
|
// БЕЗ токена зонда, тож він не має проходити через інтерсептор
|
||||||
|
// автентифікації AgentService. Різні сервіси в одному gRPC-сервері
|
||||||
|
// дозволяють це розвести без винятків усередині інтерсептора.
|
||||||
|
type EnrollService struct {
|
||||||
|
npv1.UnimplementedEnrollmentServiceServer
|
||||||
|
|
||||||
|
store *store.Store
|
||||||
|
// Куди підключатись після реєстрації. Може відрізнятись від адреси,
|
||||||
|
// на яку прийшов Enroll: балансувальник, регіональний шлюз.
|
||||||
|
controlEndpoint string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnrollService(st *store.Store, controlEndpoint string) *EnrollService {
|
||||||
|
return &EnrollService{store: st, controlEndpoint: controlEndpoint}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enroll обмінює одноразове запрошення на постійний зонд.
|
||||||
|
//
|
||||||
|
// Сертифікат у відповіді поки порожній. mTLS у продукті є (сервер
|
||||||
|
// приймає -client-ca), але він налаштовується окремо від реєстрації:
|
||||||
|
// власний CA з ротацією — це друга система, і вдавати, що вона вже
|
||||||
|
// працює, означало б віддати агенту порожній сертифікат і зламати
|
||||||
|
// з'єднання там, де воно щойно працювало.
|
||||||
|
func (s *EnrollService) Enroll(ctx context.Context, req *npv1.EnrollRequest) (*npv1.EnrollResponse, error) {
|
||||||
|
token := strings.TrimSpace(req.GetEnrollmentToken())
|
||||||
|
if token == "" {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "потрібен токен запрошення")
|
||||||
|
}
|
||||||
|
|
||||||
|
b := req.GetBuild()
|
||||||
|
agent, err := s.store.RedeemEnrollment(ctx, token,
|
||||||
|
req.GetHostname(), req.GetRequestedName(),
|
||||||
|
b.GetVersion(), b.GetOs(), b.GetArch())
|
||||||
|
|
||||||
|
if errors.Is(err, store.ErrEnrollmentInvalid) {
|
||||||
|
// Одна відповідь на «немає», «згоріло» і «вже використано»:
|
||||||
|
// розрізняти їх означало б підказувати, наскільки близько той,
|
||||||
|
// хто підбирає токени.
|
||||||
|
return nil, status.Error(codes.PermissionDenied, "запрошення недійсне")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "реєстрація: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &npv1.EnrollResponse{
|
||||||
|
AgentId: agent.ID,
|
||||||
|
AgentName: agent.Name,
|
||||||
|
ControlEndpoint: s.controlEndpoint,
|
||||||
|
AgentToken: agent.Token,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
@ -100,7 +100,18 @@ func (s *Service) StreamInterceptor(srv any, ss grpc.ServerStream, info *grpc.St
|
||||||
ctx: context.WithValue(ss.Context(), agentCtxKey, agent)})
|
ctx: context.WithValue(ss.Context(), agentCtxKey, agent)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enrollMethodPrefix — єдиний виклик, який іде без токена зонда.
|
||||||
|
//
|
||||||
|
// Інакше й бути не може: саме цим викликом токен і видається. Виняток
|
||||||
|
// заданий повним префіксом сервісу, а не «містить Enroll»: підрядок у
|
||||||
|
// назві методу — це спосіб випадково відкрити ще щось, коли сервіс
|
||||||
|
// назвуть Enrollment-чимось іще.
|
||||||
|
const enrollMethodPrefix = "/netpulse.v1.EnrollmentService/"
|
||||||
|
|
||||||
func (s *Service) UnaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
func (s *Service) UnaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||||
|
if strings.HasPrefix(info.FullMethod, enrollMethodPrefix) {
|
||||||
|
return handler(ctx, req)
|
||||||
|
}
|
||||||
agent, err := s.authenticate(ctx)
|
agent, err := s.authenticate(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
151
server/internal/httpapi/agents_manage.go
Normal file
151
server/internal/httpapi/agents_manage.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Модулі, які вміє зонд. Дублює перелік у бінарнику навмисно: увімкнути
|
||||||
|
// в UI те, чого агент не вміє, означає мовчки не отримати даних.
|
||||||
|
var validAgentModules = map[string]bool{
|
||||||
|
"icmp": true, "snmp": true, "topology": true,
|
||||||
|
"ncm": true, "syslog": true, "http": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListEnrollments(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||||
|
if !requirePerm(w, p, "agents:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := s.store.ListEnrollments(r.Context(), p.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(w, "запрошення зондів", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"enrollments": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateEnrollment видає одноразовий токен.
|
||||||
|
//
|
||||||
|
// Токен повертається рівно тут і більше ніде: у базі лежить лише його
|
||||||
|
// sha256. Якщо людина закрила вікно, не скопіювавши команду — простіше
|
||||||
|
// створити нове запрошення, ніж тримати чинний доступ у базі заради
|
||||||
|
// такого випадку.
|
||||||
|
func (s *Server) handleCreateEnrollment(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||||
|
if !requirePerm(w, p, "agents:write") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
NameHint string `json:"name_hint"`
|
||||||
|
Modules []string `json:"modules"`
|
||||||
|
TTLHours int `json:"ttl_hours"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, m := range in.Modules {
|
||||||
|
if !validAgentModules[m] {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_module", "невідомий модуль: "+m)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := time.Duration(in.TTLHours) * time.Hour
|
||||||
|
if in.TTLHours <= 0 {
|
||||||
|
ttl = store.EnrollmentTTL
|
||||||
|
}
|
||||||
|
// Тиждень — стеля. Запрошення, що живе місяць, перестає бути
|
||||||
|
// запрошенням і стає постійним ключем у чиємусь чаті.
|
||||||
|
if ttl > 7*24*time.Hour {
|
||||||
|
ttl = 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
e, err := s.store.CreateEnrollment(r.Context(), p.TenantID, p.UserID,
|
||||||
|
strings.TrimSpace(in.NameHint), in.Modules, ttl)
|
||||||
|
if err != nil {
|
||||||
|
s.writeStoreError(w, "створення запрошення", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteEnrollment(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||||
|
if !requirePerm(w, p, "agents:write") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteEnrollment(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
|
||||||
|
s.writeStoreError(w, "видалення запрошення", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Керування зондом
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||||
|
if !requirePerm(w, p, "agents:write") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Modules []string `json:"modules"`
|
||||||
|
Limits *struct {
|
||||||
|
MaxConcurrency int `json:"max_concurrency"`
|
||||||
|
IcmpRatePPS int `json:"icmp_rate_pps"`
|
||||||
|
BatchSize int `json:"batch_size"`
|
||||||
|
MaxInFlight int `json:"max_in_flight"`
|
||||||
|
} `json:"limits"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, m := range in.Modules {
|
||||||
|
if !validAgentModules[m] {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_module", "невідомий модуль: "+m)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upd := store.AgentUpdate{Name: strings.TrimSpace(in.Name), Modules: in.Modules}
|
||||||
|
if in.Limits != nil {
|
||||||
|
upd.Limits = &store.Limits{
|
||||||
|
MaxConcurrency: in.Limits.MaxConcurrency,
|
||||||
|
IcmpRatePPS: in.Limits.IcmpRatePPS,
|
||||||
|
BatchSize: in.Limits.BatchSize,
|
||||||
|
MaxInFlight: in.Limits.MaxInFlight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.UpdateAgent(r.Context(), p.TenantID, r.PathValue("id"), upd); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
writeError(w, http.StatusConflict, "duplicate", "зонд із такою назвою вже є")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeStoreError(w, "оновлення зонда", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteAgent прибирає зонд.
|
||||||
|
//
|
||||||
|
// Хости, які він опитував, лишаються — просто без зонда. Видаляти їх
|
||||||
|
// разом означало б втратити історію через заміну заліза, на якому
|
||||||
|
// стояв агент.
|
||||||
|
func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||||
|
if !requirePerm(w, p, "agents:write") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteAgent(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
|
||||||
|
s.writeStoreError(w, "видалення зонда", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
@ -149,6 +149,11 @@ func (s *Server) Handler() http.Handler {
|
||||||
mux.Handle("PATCH /api/v1/user-groups/{id}", s.authenticated(s.handlePatchUserGroup))
|
mux.Handle("PATCH /api/v1/user-groups/{id}", s.authenticated(s.handlePatchUserGroup))
|
||||||
mux.Handle("DELETE /api/v1/user-groups/{id}", s.authenticated(s.handleDeleteUserGroup))
|
mux.Handle("DELETE /api/v1/user-groups/{id}", s.authenticated(s.handleDeleteUserGroup))
|
||||||
mux.Handle("GET /api/v1/agents", s.authenticated(s.handleListAgents))
|
mux.Handle("GET /api/v1/agents", s.authenticated(s.handleListAgents))
|
||||||
|
mux.Handle("PATCH /api/v1/agents/{id}", s.authenticated(s.handleUpdateAgent))
|
||||||
|
mux.Handle("DELETE /api/v1/agents/{id}", s.authenticated(s.handleDeleteAgent))
|
||||||
|
mux.Handle("GET /api/v1/agent-enrollments", s.authenticated(s.handleListEnrollments))
|
||||||
|
mux.Handle("POST /api/v1/agent-enrollments", s.authenticated(s.handleCreateEnrollment))
|
||||||
|
mux.Handle("DELETE /api/v1/agent-enrollments/{id}", s.authenticated(s.handleDeleteEnrollment))
|
||||||
|
|
||||||
mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts))
|
mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts))
|
||||||
mux.Handle("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert))
|
mux.Handle("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert))
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -225,3 +226,89 @@ func tsOrNil(u *npv1.TaskStatusUpdate) any {
|
||||||
}
|
}
|
||||||
return u.GetTs().AsTime()
|
return u.GetTs().AsTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentUpdate — що можна змінити в зонді з UI.
|
||||||
|
//
|
||||||
|
// Порожні поля не чіпаються: форма може надіслати лише ліміти, не
|
||||||
|
// торкаючись імені й модулів.
|
||||||
|
type AgentUpdate struct {
|
||||||
|
Name string
|
||||||
|
Modules []string
|
||||||
|
Limits *Limits
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgent змінює налаштування зонда.
|
||||||
|
//
|
||||||
|
// Зміна доїжджає до живого зонда не миттєво: модулі й ліміти їдуть у
|
||||||
|
// Welcome при наступному підключенні, а план — звіркою раз на п'ять
|
||||||
|
// секунд. Це свідомо: тримати окрему чергу «керівних» повідомлень
|
||||||
|
// заради налаштувань, які міняють раз на місяць, дорожче за саму
|
||||||
|
// затримку.
|
||||||
|
func (s *Store) UpdateAgent(ctx context.Context, tenantID, agentID string, u AgentUpdate) error {
|
||||||
|
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
|
if u.Name != "" {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE core.agents SET name = $3 WHERE id = $1 AND tenant_id = $2
|
||||||
|
`, agentID, tenantID, u.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Modules != nil {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE core.agents SET enabled_modules = $3::core.slug[]
|
||||||
|
WHERE id = $1 AND tenant_id = $2
|
||||||
|
`, agentID, tenantID, u.Modules); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Limits != nil {
|
||||||
|
// Зливаємо в наявний jsonb, а не заміняємо: у limits можуть
|
||||||
|
// лежати поля, яких форма не знає, і затирати їх мовчки —
|
||||||
|
// найшвидший спосіб зламати те, чого не бачив.
|
||||||
|
patch := map[string]any{}
|
||||||
|
if u.Limits.MaxConcurrency > 0 {
|
||||||
|
patch["max_concurrency"] = u.Limits.MaxConcurrency
|
||||||
|
}
|
||||||
|
if u.Limits.IcmpRatePPS > 0 {
|
||||||
|
patch["icmp_rate_pps"] = u.Limits.IcmpRatePPS
|
||||||
|
}
|
||||||
|
if u.Limits.BatchSize > 0 {
|
||||||
|
patch["batch_size"] = u.Limits.BatchSize
|
||||||
|
}
|
||||||
|
if u.Limits.MaxInFlight > 0 {
|
||||||
|
patch["max_in_flight"] = u.Limits.MaxInFlight
|
||||||
|
}
|
||||||
|
if len(patch) > 0 {
|
||||||
|
b, err := json.Marshal(patch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE core.agents SET limits = limits || $3::jsonb
|
||||||
|
WHERE id = $1 AND tenant_id = $2
|
||||||
|
`, agentID, tenantID, string(b)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAgent прибирає зонд.
|
||||||
|
//
|
||||||
|
// Хости, які він опитував, лишаються без зонда (ON DELETE SET NULL) —
|
||||||
|
// видаляти їх разом означало б втратити історію через заміну заліза.
|
||||||
|
func (s *Store) DeleteAgent(ctx context.Context, tenantID, agentID string) error {
|
||||||
|
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
|
ct, err := tx.Exec(ctx,
|
||||||
|
`DELETE FROM core.agents WHERE id = $1 AND tenant_id = $2`, agentID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
259
server/internal/store/enrollment.go
Normal file
259
server/internal/store/enrollment.go
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/netpulse/netpulse/server/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrEnrollmentInvalid — токен не існує, згорів або вже використаний.
|
||||||
|
//
|
||||||
|
// Одна помилка на всі три випадки навмисно: розрізняти їх у відповіді
|
||||||
|
// означало б підказувати тому, хто підбирає токени, наскільки він
|
||||||
|
// близько.
|
||||||
|
var ErrEnrollmentInvalid = errors.New("запрошення недійсне")
|
||||||
|
|
||||||
|
// EnrollmentTTL — скільки живе запрошення.
|
||||||
|
//
|
||||||
|
// Доба: людина копіює команду, йде до стійки й ставить агента. Довший
|
||||||
|
// строк перетворює запрошення на постійний ключ, який лежить у чиємусь
|
||||||
|
// чаті; коротший не переживає навіть обіду.
|
||||||
|
const EnrollmentTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
// Enrollment — запрошення на реєстрацію зонда.
|
||||||
|
type Enrollment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
NameHint string `json:"name_hint,omitempty"`
|
||||||
|
Modules []string `json:"modules"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||||
|
UsedBy string `json:"used_by,omitempty"`
|
||||||
|
AgentName string `json:"agent_name,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// Token заповнюється рівно один раз — у відповіді на створення.
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEnrollment видає одноразовий токен.
|
||||||
|
func (s *Store) CreateEnrollment(ctx context.Context, tenantID, userID, nameHint string,
|
||||||
|
modules []string, ttl time.Duration) (*Enrollment, error) {
|
||||||
|
|
||||||
|
token, err := auth.NewToken("np_enr_")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = EnrollmentTTL
|
||||||
|
}
|
||||||
|
if len(modules) == 0 {
|
||||||
|
modules = []string{"icmp"}
|
||||||
|
}
|
||||||
|
|
||||||
|
e := &Enrollment{
|
||||||
|
Token: token,
|
||||||
|
Prefix: token[:min(len(token), 14)],
|
||||||
|
NameHint: nameHint,
|
||||||
|
Modules: modules,
|
||||||
|
ExpiresAt: time.Now().Add(ttl),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
|
return tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO core.agent_enrollments
|
||||||
|
(tenant_id, token_hash, prefix, name_hint, modules, expires_at, created_by)
|
||||||
|
VALUES ($1, $2, $3, NULLIF($4,''), $5::core.slug[], $6, $7)
|
||||||
|
RETURNING id::text, created_at
|
||||||
|
`, tenantID, sum[:], e.Prefix, nameHint, modules, e.ExpiresAt, nullUUID(userID)).
|
||||||
|
Scan(&e.ID, &e.CreatedAt)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEnrollments — запрошення тенанта.
|
||||||
|
func (s *Store) ListEnrollments(ctx context.Context, tenantID string) ([]Enrollment, error) {
|
||||||
|
out := []Enrollment{}
|
||||||
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT e.id::text, e.prefix, COALESCE(e.name_hint,''), e.modules::text[],
|
||||||
|
e.expires_at, e.used_at, COALESCE(e.used_by::text,''),
|
||||||
|
COALESCE(a.name,''), e.created_at
|
||||||
|
FROM core.agent_enrollments e
|
||||||
|
LEFT JOIN core.agents a ON a.id = e.used_by
|
||||||
|
WHERE e.tenant_id = $1
|
||||||
|
ORDER BY e.created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var e Enrollment
|
||||||
|
if err := rows.Scan(&e.ID, &e.Prefix, &e.NameHint, &e.Modules,
|
||||||
|
&e.ExpiresAt, &e.UsedAt, &e.UsedBy, &e.AgentName, &e.CreatedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEnrollment відкликає невикористане запрошення.
|
||||||
|
func (s *Store) DeleteEnrollment(ctx context.Context, tenantID, id string) error {
|
||||||
|
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
|
ct, err := tx.Exec(ctx,
|
||||||
|
`DELETE FROM core.agent_enrollments WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrolledAgent — те, що зонд отримує після реєстрації.
|
||||||
|
type EnrolledAgent struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemEnrollment обмінює одноразовий токен на постійний зонд.
|
||||||
|
//
|
||||||
|
// Уся операція в одній транзакції під `FOR UPDATE`: два агенти, що
|
||||||
|
// стартували з однієї команди встановлення (скопійований скрипт,
|
||||||
|
// повторний запуск), інакше створили б два зонди з одного запрошення.
|
||||||
|
//
|
||||||
|
// Токен зонда повертається один раз і в базі лежить лише хешем — так
|
||||||
|
// само, як refresh-токен сесії.
|
||||||
|
func (s *Store) RedeemEnrollment(ctx context.Context, token, hostname, requestedName,
|
||||||
|
version, osName, arch string) (*EnrolledAgent, error) {
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
|
||||||
|
var (
|
||||||
|
enrollID, tenantID string
|
||||||
|
nameHint string
|
||||||
|
modules []string
|
||||||
|
siteID *string
|
||||||
|
createdBy *string
|
||||||
|
expires time.Time
|
||||||
|
usedAt *time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
// Без InTenantTx: тенант тут ще невідомий — саме його ми й
|
||||||
|
// з'ясовуємо з токена. RLS натомість не потрібна: вибірка йде за
|
||||||
|
// унікальним хешем, який знає лише той, кому його видали.
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT id::text, tenant_id::text, COALESCE(name_hint,''), modules::text[],
|
||||||
|
site_id::text, created_by::text, expires_at, used_at
|
||||||
|
FROM core.agent_enrollments
|
||||||
|
WHERE token_hash = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`, sum[:]).Scan(&enrollID, &tenantID, &nameHint, &modules, &siteID,
|
||||||
|
&createdBy, &expires, &usedAt)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrEnrollmentInvalid
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if usedAt != nil || time.Now().After(expires) {
|
||||||
|
return nil, ErrEnrollmentInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
name := firstNonEmpty(requestedName, nameHint, hostname, "зонд")
|
||||||
|
name, err = uniqueAgentName(ctx, tx, tenantID, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
agentToken, err := auth.NewToken("np_agt_")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
agentSum := sha256.Sum256([]byte(agentToken))
|
||||||
|
|
||||||
|
var agentID string
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO core.agents
|
||||||
|
(tenant_id, site_id, name, token_hash, version, os, arch, hostname,
|
||||||
|
status, enabled_modules, enrolled_at, enrolled_by)
|
||||||
|
VALUES ($1, $2, $3, $4, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), NULLIF($8,''),
|
||||||
|
'pending', $9::core.slug[], now(), $10)
|
||||||
|
RETURNING id::text
|
||||||
|
`, tenantID, siteID, name, agentSum[:], version, osName, arch, hostname,
|
||||||
|
modules, createdBy).Scan(&agentID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE core.agent_enrollments SET used_at = now(), used_by = $2 WHERE id = $1
|
||||||
|
`, enrollID, agentID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &EnrolledAgent{ID: agentID, Name: name, Token: agentToken}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueAgentName підбирає вільне ім'я.
|
||||||
|
//
|
||||||
|
// Суфікс числом, а не відмова: людина, яка ставить п'ятий агент на
|
||||||
|
// однакових машинах, не має вигадувати імена — вона хоче, щоб він
|
||||||
|
// просто запрацював.
|
||||||
|
func uniqueAgentName(ctx context.Context, tx pgx.Tx, tenantID, base string) (string, error) {
|
||||||
|
name := base
|
||||||
|
for i := 2; i < 1000; i++ {
|
||||||
|
var taken bool
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (SELECT 1 FROM core.agents WHERE tenant_id = $1 AND name = $2)
|
||||||
|
`, tenantID, name).Scan(&taken); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !taken {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
name = fmt.Sprintf("%s-%d", base, i)
|
||||||
|
}
|
||||||
|
return "", errors.New("не вдалося підібрати ім'я зонда")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
@ -95,6 +95,10 @@ type AgentSummary struct {
|
||||||
EnabledModules []string `json:"enabled_modules"`
|
EnabledModules []string `json:"enabled_modules"`
|
||||||
Health map[string]any `json:"health"`
|
Health map[string]any `json:"health"`
|
||||||
DeviceCount int `json:"device_count"`
|
DeviceCount int `json:"device_count"`
|
||||||
|
// Ліміти опитування. Потрібні формі налаштувань: показати типові
|
||||||
|
// значення там, де задані свої, означало б стерти їх першим же
|
||||||
|
// збереженням.
|
||||||
|
Limits map[string]any `json:"limits"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAgents — стан зондів для UI.
|
// ListAgents — стан зондів для UI.
|
||||||
|
|
@ -109,7 +113,8 @@ func (s *Store) ListAgents(ctx context.Context, tenantID string) ([]AgentSummary
|
||||||
rows, err := tx.Query(ctx, `
|
rows, err := tx.Query(ctx, `
|
||||||
SELECT a.id::text, a.name, a.status::text,
|
SELECT a.id::text, a.name, a.status::text,
|
||||||
COALESCE(a.version,''), COALESCE(a.os,''), COALESCE(a.arch,''),
|
COALESCE(a.version,''), COALESCE(a.os,''), COALESCE(a.arch,''),
|
||||||
COALESCE(a.hostname,''), a.last_heartbeat_at, a.enabled_modules, a.health,
|
COALESCE(a.hostname,''), a.last_heartbeat_at, a.enabled_modules,
|
||||||
|
a.health, a.limits,
|
||||||
(SELECT count(*) FROM inv.devices d
|
(SELECT count(*) FROM inv.devices d
|
||||||
WHERE d.agent_id = a.id AND d.deleted_at IS NULL)
|
WHERE d.agent_id = a.id AND d.deleted_at IS NULL)
|
||||||
FROM core.agents a
|
FROM core.agents a
|
||||||
|
|
@ -125,7 +130,7 @@ func (s *Store) ListAgents(ctx context.Context, tenantID string) ([]AgentSummary
|
||||||
var a AgentSummary
|
var a AgentSummary
|
||||||
if err := rows.Scan(&a.ID, &a.Name, &a.Status, &a.Version, &a.OS, &a.Arch,
|
if err := rows.Scan(&a.ID, &a.Name, &a.Status, &a.Version, &a.OS, &a.Arch,
|
||||||
&a.Hostname, &a.LastHeartbeatAt, &a.EnabledModules, &a.Health,
|
&a.Hostname, &a.LastHeartbeatAt, &a.EnabledModules, &a.Health,
|
||||||
&a.DeviceCount); err != nil {
|
&a.Limits, &a.DeviceCount); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if a.EnabledModules == nil {
|
if a.EnabledModules == nil {
|
||||||
|
|
@ -134,6 +139,9 @@ func (s *Store) ListAgents(ctx context.Context, tenantID string) ([]AgentSummary
|
||||||
if a.Health == nil {
|
if a.Health == nil {
|
||||||
a.Health = map[string]any{}
|
a.Health = map[string]any{}
|
||||||
}
|
}
|
||||||
|
if a.Limits == nil {
|
||||||
|
a.Limits = map[string]any{}
|
||||||
|
}
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
}
|
}
|
||||||
return rows.Err()
|
return rows.Err()
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import type {
|
||||||
UndoResult,
|
UndoResult,
|
||||||
BackupDefaults,
|
BackupDefaults,
|
||||||
Dashboard,
|
Dashboard,
|
||||||
|
Enrollment,
|
||||||
DeviceGraph,
|
DeviceGraph,
|
||||||
Icon,
|
Icon,
|
||||||
MapPermission,
|
MapPermission,
|
||||||
|
|
@ -654,4 +655,36 @@ export const api = {
|
||||||
request<{ graphs: DeviceGraph[] }>(`/api/v1/devices/${deviceID}/graphs`).then(
|
request<{ graphs: DeviceGraph[] }>(`/api/v1/devices/${deviceID}/graphs`).then(
|
||||||
(r) => r.graphs ?? [],
|
(r) => r.graphs ?? [],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// --- зонди ---
|
||||||
|
|
||||||
|
listEnrollments: () =>
|
||||||
|
request<{ enrollments: Enrollment[] }>('/api/v1/agent-enrollments').then(
|
||||||
|
(r) => r.enrollments ?? [],
|
||||||
|
),
|
||||||
|
|
||||||
|
createEnrollment: (e: { name_hint?: string; modules?: string[]; ttl_hours?: number }) =>
|
||||||
|
request<Enrollment>('/api/v1/agent-enrollments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(e),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteEnrollment: (id: string) =>
|
||||||
|
request<void>(`/api/v1/agent-enrollments/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
updateAgent: (
|
||||||
|
id: string,
|
||||||
|
a: {
|
||||||
|
name?: string
|
||||||
|
modules?: string[]
|
||||||
|
limits?: {
|
||||||
|
max_concurrency?: number
|
||||||
|
icmp_rate_pps?: number
|
||||||
|
batch_size?: number
|
||||||
|
max_in_flight?: number
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) => request<void>(`/api/v1/agents/${id}`, { method: 'PATCH', body: JSON.stringify(a) }),
|
||||||
|
|
||||||
|
deleteAgent: (id: string) => request<void>(`/api/v1/agents/${id}`, { method: 'DELETE' }),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
188
web/src/components/AgentEnroll.tsx
Normal file
188
web/src/components/AgentEnroll.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { Button, ErrorNote, Field, Modal, inputClass } from './ui'
|
||||||
|
import type { Enrollment } from '../types'
|
||||||
|
|
||||||
|
// Модулі, які вміє зонд. Перелік дублює бінарник навмисно: увімкнути
|
||||||
|
// тут те, чого агент не вміє, означає мовчки не отримати даних.
|
||||||
|
const MODULES: { key: string; label: string; hint: string }[] = [
|
||||||
|
{ key: 'icmp', label: 'ICMP', hint: 'пінг — працює будь-де без доступів' },
|
||||||
|
{ key: 'snmp', label: 'SNMP', hint: 'метрики й лічильники портів' },
|
||||||
|
{ key: 'topology', label: 'Топологія', hint: 'LLDP/CDP — сусіди на мапі' },
|
||||||
|
{ key: 'ncm', label: 'NCM', hint: 'збір конфігів по SSH/Telnet' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрошення зонда.
|
||||||
|
*
|
||||||
|
* Токен показується рівно один раз: у базі лежить лише його sha256.
|
||||||
|
* Якщо вікно закрили, не скопіювавши команду — простіше створити нове
|
||||||
|
* запрошення, ніж тримати чинний доступ у базі заради такого випадку.
|
||||||
|
*/
|
||||||
|
export function AgentEnroll({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [modules, setModules] = useState<string[]>(['icmp', 'snmp'])
|
||||||
|
const [ttl, setTtl] = useState('24')
|
||||||
|
const [result, setResult] = useState<Enrollment | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (result) return <EnrollResult enrollment={result} onClose={onClose} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Новий зонд" onClose={onClose} wide>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||||
|
Зонд сам підключається до сервера — відкривати порти в мережі клієнта не потрібно.
|
||||||
|
Запрошення одноразове: після реєстрації воно згорає.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Назва зонда" hint="Порожньо — візьметься імʼя хоста">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
autoFocus
|
||||||
|
placeholder="probe-київ"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Запрошення дійсне, годин" hint="Стеля — тиждень">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="168"
|
||||||
|
value={ttl}
|
||||||
|
onChange={(e) => setTtl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Модулі" hint="Далі керуються з картки зонда">
|
||||||
|
<ul className="grid gap-1.5 sm:grid-cols-2">
|
||||||
|
{MODULES.map((m) => (
|
||||||
|
<li key={m.key}>
|
||||||
|
<label className="flex items-start gap-2 rounded border border-slate-800 bg-slate-900/50 p-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={modules.includes(m.key)}
|
||||||
|
onChange={() =>
|
||||||
|
setModules((prev) =>
|
||||||
|
prev.includes(m.key)
|
||||||
|
? prev.filter((x) => x !== m.key)
|
||||||
|
: [...prev, m.key],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm text-slate-200">{m.label}</span>
|
||||||
|
<span className="block text-[11px] text-slate-600">{m.hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<ErrorNote>{err}</ErrorNote>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button onClick={onClose}>Скасувати</Button>
|
||||||
|
<Button
|
||||||
|
kind="primary"
|
||||||
|
disabled={busy || modules.length === 0}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
const e = await api.createEnrollment({
|
||||||
|
name_hint: name.trim(),
|
||||||
|
modules,
|
||||||
|
ttl_hours: Number(ttl) || 24,
|
||||||
|
})
|
||||||
|
setResult(e)
|
||||||
|
onCreated()
|
||||||
|
} catch (e2) {
|
||||||
|
setErr(e2 instanceof Error ? e2.message : String(e2))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? 'Створення…' : 'Створити запрошення'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnrollResult({
|
||||||
|
enrollment,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
enrollment: Enrollment
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState('')
|
||||||
|
|
||||||
|
// Адреса сервера з того ж походження, що й веб: у типовій інсталяції
|
||||||
|
// це та сама машина, і питати її в людини означало б питати те, що
|
||||||
|
// вона щойно ввела в адресний рядок.
|
||||||
|
const host = window.location.hostname
|
||||||
|
const cmd = `netpulse-agent -server ${host}:9443 -enroll ${enrollment.token}`
|
||||||
|
|
||||||
|
const copy = (what: string, text: string) => {
|
||||||
|
void navigator.clipboard.writeText(text)
|
||||||
|
setCopied(what)
|
||||||
|
setTimeout(() => setCopied(''), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Зонд запрошено" onClose={onClose} wide>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="rounded border border-amber-900/60 bg-amber-950/20 p-2.5 text-[11px] leading-relaxed text-amber-200">
|
||||||
|
Токен показується один раз — у базі лежить лише його відбиток. Скопіюйте команду
|
||||||
|
зараз; якщо загубиться, створіть нове запрошення.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Field label="Команда встановлення">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<code className="min-w-0 flex-1 overflow-x-auto rounded border border-slate-800 bg-slate-950 px-2 py-2 font-mono text-[11px] text-slate-300">
|
||||||
|
{cmd}
|
||||||
|
</code>
|
||||||
|
<Button onClick={() => copy('cmd', cmd)}>
|
||||||
|
{copied === 'cmd' ? '✓' : 'Копіювати'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Тільки токен" hint="Якщо агент уже встановлений">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<code className="min-w-0 flex-1 overflow-x-auto rounded border border-slate-800 bg-slate-950 px-2 py-2 font-mono text-[11px] text-slate-300">
|
||||||
|
{enrollment.token}
|
||||||
|
</code>
|
||||||
|
<Button onClick={() => copy('tok', enrollment.token ?? '')}>
|
||||||
|
{copied === 'tok' ? '✓' : 'Копіювати'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||||
|
Після реєстрації агент збереже посвідчення в{' '}
|
||||||
|
<code className="text-slate-500">/etc/netpulse/agent.json</code> і надалі
|
||||||
|
запускатиметься без токена. Дійсне до{' '}
|
||||||
|
{new Date(enrollment.expires_at).toLocaleString('uk-UA')}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<Button kind="primary" onClick={onClose}>
|
||||||
|
Готово
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,96 +1,353 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { Card, ErrorNote, PageHeader, Spinner, StatusBadge, ago } from '../components/ui'
|
import { session } from '../api/session'
|
||||||
import type { AgentSummary } from '../types'
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Confirm,
|
||||||
|
ErrorNote,
|
||||||
|
Field,
|
||||||
|
Modal,
|
||||||
|
PageBody,
|
||||||
|
PageHeader,
|
||||||
|
Spinner,
|
||||||
|
StatusBadge,
|
||||||
|
ago,
|
||||||
|
inputClass,
|
||||||
|
plural,
|
||||||
|
} from '../components/ui'
|
||||||
|
import type { ConfirmRequest } from '../components/ui'
|
||||||
|
import { AgentEnroll } from '../components/AgentEnroll'
|
||||||
|
import type { AgentSummary, Enrollment } from '../types'
|
||||||
|
|
||||||
|
const MODULES = ['icmp', 'snmp', 'topology', 'ncm']
|
||||||
|
|
||||||
export function AgentsPage() {
|
export function AgentsPage() {
|
||||||
|
const canWrite = session.can('agents:write')
|
||||||
|
|
||||||
const [agents, setAgents] = useState<AgentSummary[]>([])
|
const [agents, setAgents] = useState<AgentSummary[]>([])
|
||||||
|
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [enrolling, setEnrolling] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<AgentSummary | null>(null)
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
const reload = useCallback(() => {
|
||||||
api
|
Promise.all([
|
||||||
.listAgents()
|
api.listAgents(),
|
||||||
.then(setAgents)
|
canWrite ? api.listEnrollments().catch(() => [] as Enrollment[]) : Promise.resolve([]),
|
||||||
|
])
|
||||||
|
.then(([a, e]) => {
|
||||||
|
setAgents(a)
|
||||||
|
setEnrollments(e)
|
||||||
|
})
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [canWrite])
|
||||||
|
|
||||||
|
useEffect(reload, [reload])
|
||||||
|
|
||||||
const online = agents.filter((a) => a.status === 'online').length
|
const online = agents.filter((a) => a.status === 'online').length
|
||||||
|
const pending = enrollments.filter((e) => !e.used_at && new Date(e.expires_at) > new Date())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Зонди"
|
title="Зонди"
|
||||||
subtitle={loading ? undefined : `${online} з ${agents.length} на зв’язку`}
|
subtitle={loading ? undefined : `${online} з ${agents.length} на звʼязку`}
|
||||||
|
actions={
|
||||||
|
canWrite ? (
|
||||||
|
<Button kind="primary" onClick={() => setEnrolling(true)}>
|
||||||
|
+ Зонд
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
|
<PageBody className="space-y-4">
|
||||||
{error && <ErrorNote>{error}</ErrorNote>}
|
<ErrorNote>{error}</ErrorNote>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : agents.length === 0 ? (
|
|
||||||
<p className="px-4 py-16 text-center text-sm text-slate-600">
|
|
||||||
Зондів ще немає. Зонд сам підключається до сервера — відкривати порти в мережі
|
|
||||||
клієнта не потрібно.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<>
|
||||||
{agents.map((a) => (
|
{pending.length > 0 && (
|
||||||
<li key={a.id}>
|
<Card className="p-3">
|
||||||
<Card className="p-3">
|
<div className="mb-2 text-xs font-medium text-slate-400">
|
||||||
<div className="flex items-center gap-2">
|
Запрошення, які чекають на зонд
|
||||||
<span className="truncate font-medium text-slate-100">{a.name}</span>
|
</div>
|
||||||
<span className="ml-auto">
|
<ul className="divide-y divide-slate-800/70">
|
||||||
<StatusBadge
|
{pending.map((e) => (
|
||||||
status={a.status}
|
<li key={e.id} className="flex flex-wrap items-center gap-2 py-2 text-xs">
|
||||||
label={a.status === 'online' ? 'на зв’язку' : 'офлайн'}
|
<code className="font-mono text-slate-400">{e.prefix}…</code>
|
||||||
/>
|
{e.name_hint && <span className="text-slate-300">{e.name_hint}</span>}
|
||||||
</span>
|
<span className="text-slate-600">
|
||||||
</div>
|
до {new Date(e.expires_at).toLocaleString('uk-UA')}
|
||||||
|
|
||||||
<dl className="mt-2.5 space-y-1 text-xs">
|
|
||||||
<Pair k="Платформа" v={`${a.os ?? '?'}/${a.arch ?? '?'}`} />
|
|
||||||
<Pair k="Версія" v={a.version ?? '—'} />
|
|
||||||
<Pair k="Пристроїв" v={String(a.device_count)} />
|
|
||||||
<Pair k="Останній пульс" v={ago(a.last_heartbeat_at)} />
|
|
||||||
{typeof a.health?.rss_bytes === 'number' && (
|
|
||||||
<Pair
|
|
||||||
k="Памʼять"
|
|
||||||
v={`${((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex flex-wrap gap-1">
|
|
||||||
{a.enabled_modules.map((m) => (
|
|
||||||
<span
|
|
||||||
key={m}
|
|
||||||
className="rounded border border-slate-700 bg-slate-800 px-1.5 py-0.5 text-[11px] text-slate-400"
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
<Button
|
||||||
{a.enabled_modules.length === 0 && (
|
kind="danger"
|
||||||
<span className="text-[11px] text-slate-600">модулі не активовані</span>
|
className="ml-auto"
|
||||||
)}
|
onClick={() =>
|
||||||
</div>
|
setConfirm({
|
||||||
</Card>
|
title: 'Відкликати запрошення',
|
||||||
</li>
|
message: (
|
||||||
))}
|
<>
|
||||||
</ul>
|
Запрошення <code>{e.prefix}…</code> перестане працювати.
|
||||||
)}
|
</>
|
||||||
|
),
|
||||||
|
detail:
|
||||||
|
'Якщо агент уже встиг зареєструватися, це його не зачепить — запрошення згорає при першому використанні.',
|
||||||
|
confirmLabel: 'Відкликати',
|
||||||
|
onConfirm: async () => {
|
||||||
|
await api.deleteEnrollment(e.id)
|
||||||
|
reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Відкликати
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="mt-4 text-xs leading-relaxed text-slate-600">
|
{agents.length === 0 ? (
|
||||||
Керування набором модулів із інтерфейсу ще не реалізоване — транспорт для цього
|
<p className="py-12 text-center text-sm leading-relaxed text-slate-600">
|
||||||
готовий, але сторінка налаштувань зонда попереду.
|
Зондів ще немає. Зонд сам підключається до сервера — відкривати порти в мережі
|
||||||
</p>
|
клієнта не потрібно.
|
||||||
</div>
|
{canWrite && ' Натисніть «+ Зонд», щоб отримати команду встановлення.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{agents.map((a) => (
|
||||||
|
<li key={a.id}>
|
||||||
|
<Card className="flex min-w-0 flex-col p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-medium text-slate-100">{a.name}</span>
|
||||||
|
<span className="ml-auto shrink-0">
|
||||||
|
<StatusBadge
|
||||||
|
status={a.status}
|
||||||
|
label={
|
||||||
|
a.status === 'online'
|
||||||
|
? 'на звʼязку'
|
||||||
|
: a.status === 'pending'
|
||||||
|
? 'очікує'
|
||||||
|
: 'офлайн'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-2.5 space-y-1 text-xs">
|
||||||
|
<Pair k="Платформа" v={`${a.os ?? '?'}/${a.arch ?? '?'}`} />
|
||||||
|
<Pair k="Версія" v={a.version ?? '—'} />
|
||||||
|
<Pair k="Хостів" v={String(a.device_count)} />
|
||||||
|
<Pair k="Останній пульс" v={ago(a.last_heartbeat_at)} />
|
||||||
|
{typeof a.health?.rss_bytes === 'number' && (
|
||||||
|
<Pair
|
||||||
|
k="Памʼять"
|
||||||
|
v={`${((a.health.rss_bytes as number) / 1048576).toFixed(1)} МБ`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex flex-wrap gap-1">
|
||||||
|
{a.enabled_modules.map((m) => (
|
||||||
|
<span
|
||||||
|
key={m}
|
||||||
|
className="rounded border border-slate-700 bg-slate-800 px-1.5 py-0.5 text-[11px] text-slate-400"
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{a.enabled_modules.length === 0 && (
|
||||||
|
<span className="text-[11px] text-slate-600">модулі не активовані</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canWrite && (
|
||||||
|
<div className="mt-auto flex justify-end gap-1.5 pt-3">
|
||||||
|
<Button onClick={() => setEditing(a)}>Налаштувати</Button>
|
||||||
|
<Button
|
||||||
|
kind="danger"
|
||||||
|
onClick={() =>
|
||||||
|
setConfirm({
|
||||||
|
title: 'Видалити зонд',
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Зонд <b>{a.name}</b> буде видалено.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
detail:
|
||||||
|
a.device_count > 0
|
||||||
|
? `${plural(a.device_count, 'хост', 'хости', 'хостів')} лишиться без зонда й перестане опитуватись, доки їм не призначать інший. Зібрані дані збережуться.`
|
||||||
|
: 'Щоб під’єднати його знову, знадобиться нове запрошення.',
|
||||||
|
onConfirm: async () => {
|
||||||
|
await api.deleteAgent(a.id)
|
||||||
|
reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PageBody>
|
||||||
|
|
||||||
|
{enrolling && (
|
||||||
|
<AgentEnroll onClose={() => setEnrolling(false)} onCreated={reload} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<AgentForm
|
||||||
|
agent={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditing(null)
|
||||||
|
reload()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Налаштування зонда: імʼя, модулі, ліміти.
|
||||||
|
*
|
||||||
|
* Зміни доїжджають не миттєво — модулі й ліміти їдуть у Welcome при
|
||||||
|
* наступному підключенні. Форма каже це прямим текстом: мовчазна
|
||||||
|
* затримка виглядає як «не зберіглося».
|
||||||
|
*/
|
||||||
|
function AgentForm({
|
||||||
|
agent,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
agent: AgentSummary
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(agent.name)
|
||||||
|
const [modules, setModules] = useState<string[]>(agent.enabled_modules ?? [])
|
||||||
|
const [concurrency, setConcurrency] = useState(String(agent.limits?.max_concurrency ?? 256))
|
||||||
|
const [icmpRate, setIcmpRate] = useState(String(agent.limits?.icmp_rate_pps ?? 500))
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`Зонд: ${agent.name}`} onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Назва">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Модулі" hint="Вимкнений модуль перестає опитувати, дані лишаються">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{MODULES.map((m) => {
|
||||||
|
const on = modules.includes(m)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setModules((prev) =>
|
||||||
|
prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`rounded border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
on
|
||||||
|
? 'border-sky-600 bg-sky-950/50 text-sky-200'
|
||||||
|
: 'border-slate-700 bg-slate-800/60 text-slate-400 hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Паралельних перевірок" hint="Скільки чеків одночасно">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="4096"
|
||||||
|
value={concurrency}
|
||||||
|
onChange={(e) => setConcurrency(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Темп ICMP, пакетів/с" hint="Щоб не залити канал пінгом">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20000"
|
||||||
|
value={icmpRate}
|
||||||
|
onChange={(e) => setIcmpRate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||||
|
Модулі й ліміти доїдуть до зонда при наступному підключенні. Перелік перевірок —
|
||||||
|
швидше, звіркою плану раз на кілька секунд.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ErrorNote>{err}</ErrorNote>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button onClick={onClose}>Скасувати</Button>
|
||||||
|
<Button
|
||||||
|
kind="primary"
|
||||||
|
disabled={busy || !name.trim()}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
await api.updateAgent(agent.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
modules,
|
||||||
|
limits: {
|
||||||
|
max_concurrency: Number(concurrency) || 0,
|
||||||
|
icmp_rate_pps: Number(icmpRate) || 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? 'Збереження…' : 'Зберегти'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Pair({ k, v }: { k: string; v: string }) {
|
function Pair({ k, v }: { k: string; v: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between gap-3">
|
<div className="flex justify-between gap-3">
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,22 @@ export interface AgentSummary {
|
||||||
enabled_modules: string[]
|
enabled_modules: string[]
|
||||||
health: Record<string, unknown>
|
health: Record<string, unknown>
|
||||||
device_count: number
|
device_count: number
|
||||||
|
limits?: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Одноразове запрошення на реєстрацію зонда. */
|
||||||
|
export type Enrollment = {
|
||||||
|
id: string
|
||||||
|
prefix: string
|
||||||
|
name_hint?: string
|
||||||
|
modules: string[]
|
||||||
|
expires_at: string
|
||||||
|
used_at?: string
|
||||||
|
used_by?: string
|
||||||
|
agent_name?: string
|
||||||
|
created_at: string
|
||||||
|
/** Приходить рівно один раз — у відповіді на створення. */
|
||||||
|
token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue