diff --git a/HISTORY.md b/HISTORY.md index 946a30b..ce1b283 100644 --- a/HISTORY.md +++ b/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, значення застосовані +``` + diff --git a/ROADMAP.md b/ROADMAP.md index b3df72a..fe5b8c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -210,7 +210,16 @@ diff між ревізіями. Зараз `commit_sha` тимчасово мі --- -## Етап 8. Керування зондом із UI +## Етап 8. Керування зондом із UI — ✅ зроблено 2026-08-25 + +> **Зроблено:** `EnrollmentService` із одноразовими запрошеннями, файл +> посвідчення на агенті, сторінка зондів із командою встановлення, +> керування модулями й лімітами, видалення зонда. Перевірено наскрізно. +> +> **Лишилось:** справжній CA з підписом CSR — контракт це передбачає, +> але власний центр сертифікації з ротацією є окремою системою. + +## Етап 8 (початковий план) Транспорт готовий повністю: сервер уже вміє штовхати `ModuleControl` (які модулі вмикати), `TaskDelta` (що опитувати), ліміти в `Welcome` (паралельність, розмір @@ -299,7 +308,7 @@ Diff* — усе з ТЗ. 2. ~~**Етап 9 (алерти)** — без сповіщень це не моніторинг.~~ ✅ 3. **Етап 6 (шаблони)** — знімає потребу дописувати Go під кожен вендор; що раніше, то менше захардкодженого коду доведеться викидати. -4. **Етап 8 (керування зондом)** — дешевий і робить онбординг можливим. +4. ~~**Етап 8 (керування зондом)** — дешевий і робить онбординг можливим.~~ ✅ 5. **Етап 7 (NCM)** — головний аргумент Enterprise-тарифу. 6. **Етап 10 (мобільний)** — після того, як є що показувати. diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go index 713bbd8..a7408e3 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -44,6 +44,14 @@ type Config struct { // Модулі, увімкнені до першої відповіді сервера. DefaultModules []string + + // Одноразовий токен запрошення. Якщо заданий — зонд спершу + // реєструється, зберігає посвідчення й лише потім працює. + EnrollToken string + // Бажане ім'я зонда при реєстрації. + EnrollName string + // Де лежить посвідчення між запусками. + IdentityPath 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"), "модулі, увімкнені до відповіді сервера (через кому)") + 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 { return nil, err } @@ -113,12 +128,13 @@ func (c *Config) validate() error { if c.Endpoint == "" { return errors.New("не вказано адресу сервера (-server або NETPULSE_SERVER)") } - if c.AgentID == "" { - return errors.New("не вказано -agent-id: зонд має бути зареєстрований через Enroll") - } - if c.Token == "" { - return errors.New("не вказано -token (або NETPULSE_TOKEN)") - } + // Ідентичність тут не перевіряємо взагалі. + // + // До реєстрації її ще немає, після — вона приходить із файлу + // посвідчення, про який validate не знає. Перевірка живе в main, + // коли всі три джерела (прапорці, файл, реєстрація) вже враховані: + // інакше запуск із самим -identity падав із «зонд не + // зареєстрований», хоча посвідчення лежало поруч. if !c.Insecure && (c.CertFile == "" || c.KeyFile == "") { return errors.New("потрібні -cert і -key для mTLS (або -insecure для локального стенду)") } diff --git a/agent/internal/config/enroll.go b/agent/internal/config/enroll.go new file mode 100644 index 0000000..3b76f51 --- /dev/null +++ b/agent/internal/config/enroll.go @@ -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 +} diff --git a/db/migrations/0023_agent_enrollment.sql b/db/migrations/0023_agent_enrollment.sql new file mode 100644 index 0000000..82e3ee9 --- /dev/null +++ b/db/migrations/0023_agent_enrollment.sql @@ -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; diff --git a/gen/go/netpulse/v1/agent.pb.go b/gen/go/netpulse/v1/agent.pb.go index 6083b74..a28fd22 100644 --- a/gen/go/netpulse/v1/agent.pb.go +++ b/gen/go/netpulse/v1/agent.pb.go @@ -12,7 +12,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.12 -// protoc v3.21.12 +// protoc (unknown) // source: netpulse/v1/agent.proto 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"` - 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() { @@ -396,6 +404,13 @@ func (x *EnrollResponse) GetControlEndpoint() string { return "" } +func (x *EnrollResponse) GetAgentToken() string { + if x != nil { + return x.AgentToken + } + return "" +} + type ControlUp struct { 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" + "\bhostname\x18\x03 \x01(\tR\bhostname\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" + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1d\n" + "\n" + @@ -2220,7 +2235,9 @@ const file_netpulse_v1_agent_proto_rawDesc = "" + "\vcertificate\x18\x03 \x01(\fR\vcertificate\x12\x19\n" + "\bca_chain\x18\x04 \x01(\fR\acaChain\x12P\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" + "\x03seq\x18\x01 \x01(\x04R\x03seq\x12*\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" + "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" + - "\fUploadConfig\x12\x19.netpulse.v1.ConfigUpload\x1a\x1a.netpulse.v1.ConfigReceipt(\x01B задачі й падав на кожній із «немає SNMP-креденшелів» — пачка доступів видається на `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` і файл посвідчення. + +Якщо запис не вдався, повідомлення несе сам токен: інакше довелося б +створювати нове запрошення лише через те, що каталог виявився +недоступним. + ### Історія метрик | Метод | Шлях | Призначення | diff --git a/server/internal/grpcapi/enroll.go b/server/internal/grpcapi/enroll.go new file mode 100644 index 0000000..49d57bd --- /dev/null +++ b/server/internal/grpcapi/enroll.go @@ -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 +} diff --git a/server/internal/grpcapi/service.go b/server/internal/grpcapi/service.go index f95ac67..0cdd170 100644 --- a/server/internal/grpcapi/service.go +++ b/server/internal/grpcapi/service.go @@ -100,7 +100,18 @@ func (s *Service) StreamInterceptor(srv any, ss grpc.ServerStream, info *grpc.St 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) { + if strings.HasPrefix(info.FullMethod, enrollMethodPrefix) { + return handler(ctx, req) + } agent, err := s.authenticate(ctx) if err != nil { return nil, err diff --git a/server/internal/httpapi/agents_manage.go b/server/internal/httpapi/agents_manage.go new file mode 100644 index 0000000..52dafed --- /dev/null +++ b/server/internal/httpapi/agents_manage.go @@ -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) +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 32f4bf7..3f4c22d 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -149,6 +149,11 @@ func (s *Server) Handler() http.Handler { 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("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("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert)) diff --git a/server/internal/store/agents.go b/server/internal/store/agents.go index d63cde6..1b5056e 100644 --- a/server/internal/store/agents.go +++ b/server/internal/store/agents.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "crypto/sha256" "crypto/subtle" "errors" @@ -225,3 +226,89 @@ func tsOrNil(u *npv1.TaskStatusUpdate) any { } 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 + }) +} diff --git a/server/internal/store/enrollment.go b/server/internal/store/enrollment.go new file mode 100644 index 0000000..85cc1fc --- /dev/null +++ b/server/internal/store/enrollment.go @@ -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 +} diff --git a/server/internal/store/inventory.go b/server/internal/store/inventory.go index b89ccc6..2883e38 100644 --- a/server/internal/store/inventory.go +++ b/server/internal/store/inventory.go @@ -95,6 +95,10 @@ type AgentSummary struct { EnabledModules []string `json:"enabled_modules"` Health map[string]any `json:"health"` DeviceCount int `json:"device_count"` + // Ліміти опитування. Потрібні формі налаштувань: показати типові + // значення там, де задані свої, означало б стерти їх першим же + // збереженням. + Limits map[string]any `json:"limits"` } // ListAgents — стан зондів для UI. @@ -109,7 +113,8 @@ func (s *Store) ListAgents(ctx context.Context, tenantID string) ([]AgentSummary rows, err := tx.Query(ctx, ` SELECT a.id::text, a.name, a.status::text, 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 WHERE d.agent_id = a.id AND d.deleted_at IS NULL) FROM core.agents a @@ -125,7 +130,7 @@ func (s *Store) ListAgents(ctx context.Context, tenantID string) ([]AgentSummary var a AgentSummary 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.DeviceCount); err != nil { + &a.Limits, &a.DeviceCount); err != nil { return err } if a.EnabledModules == nil { @@ -134,6 +139,9 @@ func (s *Store) ListAgents(ctx context.Context, tenantID string) ([]AgentSummary if a.Health == nil { a.Health = map[string]any{} } + if a.Limits == nil { + a.Limits = map[string]any{} + } out = append(out, a) } return rows.Err() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d667783..da38499 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -28,6 +28,7 @@ import type { UndoResult, BackupDefaults, Dashboard, + Enrollment, DeviceGraph, Icon, MapPermission, @@ -654,4 +655,36 @@ export const api = { request<{ graphs: DeviceGraph[] }>(`/api/v1/devices/${deviceID}/graphs`).then( (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('/api/v1/agent-enrollments', { + method: 'POST', + body: JSON.stringify(e), + }), + + deleteEnrollment: (id: string) => + request(`/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(`/api/v1/agents/${id}`, { method: 'PATCH', body: JSON.stringify(a) }), + + deleteAgent: (id: string) => request(`/api/v1/agents/${id}`, { method: 'DELETE' }), } diff --git a/web/src/components/AgentEnroll.tsx b/web/src/components/AgentEnroll.tsx new file mode 100644 index 0000000..88122cb --- /dev/null +++ b/web/src/components/AgentEnroll.tsx @@ -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(['icmp', 'snmp']) + const [ttl, setTtl] = useState('24') + const [result, setResult] = useState(null) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + if (result) return + + return ( + +
+

+ Зонд сам підключається до сервера — відкривати порти в мережі клієнта не потрібно. + Запрошення одноразове: після реєстрації воно згорає. +

+ +
+ + setName(e.target.value)} + /> + + + setTtl(e.target.value)} + /> + +
+ + +
    + {MODULES.map((m) => ( +
  • + +
  • + ))} +
+
+ + {err} + +
+ + +
+
+
+ ) +} + +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 ( + +
+

+ Токен показується один раз — у базі лежить лише його відбиток. Скопіюйте команду + зараз; якщо загубиться, створіть нове запрошення. +

+ + +
+ + {cmd} + + +
+
+ + +
+ + {enrollment.token} + + +
+
+ +

+ Після реєстрації агент збереже посвідчення в{' '} + /etc/netpulse/agent.json і надалі + запускатиметься без токена. Дійсне до{' '} + {new Date(enrollment.expires_at).toLocaleString('uk-UA')}. +

+ +
+ +
+
+
+ ) +} diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx index d84575d..b541213 100644 --- a/web/src/pages/AgentsPage.tsx +++ b/web/src/pages/AgentsPage.tsx @@ -1,96 +1,353 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { api } from '../api/client' -import { Card, ErrorNote, PageHeader, Spinner, StatusBadge, ago } from '../components/ui' -import type { AgentSummary } from '../types' +import { session } from '../api/session' +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() { + const canWrite = session.can('agents:write') + const [agents, setAgents] = useState([]) + const [enrollments, setEnrollments] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [enrolling, setEnrolling] = useState(false) + const [editing, setEditing] = useState(null) + const [confirm, setConfirm] = useState(null) - useEffect(() => { - api - .listAgents() - .then(setAgents) + const reload = useCallback(() => { + Promise.all([ + api.listAgents(), + 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))) .finally(() => setLoading(false)) - }, []) + }, [canWrite]) + + useEffect(reload, [reload]) 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 ( <> setEnrolling(true)}> + + Зонд + + ) : null + } /> -
- {error && {error}} + + {error} {loading ? ( - ) : agents.length === 0 ? ( -

- Зондів ще немає. Зонд сам підключається до сервера — відкривати порти в мережі - клієнта не потрібно. -

) : ( -
    - {agents.map((a) => ( -
  • - -
    - {a.name} - - - -
    - -
    - - - - - {typeof a.health?.rss_bytes === 'number' && ( - - )} -
    - -
    - {a.enabled_modules.map((m) => ( - - {m} + <> + {pending.length > 0 && ( + +
    + Запрошення, які чекають на зонд +
    +
      + {pending.map((e) => ( +
    • + {e.prefix}… + {e.name_hint && {e.name_hint}} + + до {new Date(e.expires_at).toLocaleString('uk-UA')} - ))} - {a.enabled_modules.length === 0 && ( - модулі не активовані - )} -
    -
    -
  • - ))} -
- )} + + + ))} + + + )} -

- Керування набором модулів із інтерфейсу ще не реалізоване — транспорт для цього - готовий, але сторінка налаштувань зонда попереду. -

-
+ {agents.length === 0 ? ( +

+ Зондів ще немає. Зонд сам підключається до сервера — відкривати порти в мережі + клієнта не потрібно. + {canWrite && ' Натисніть «+ Зонд», щоб отримати команду встановлення.'} +

+ ) : ( +
    + {agents.map((a) => ( +
  • + +
    + {a.name} + + + +
    + +
    + + + + + {typeof a.health?.rss_bytes === 'number' && ( + + )} +
    + +
    + {a.enabled_modules.map((m) => ( + + {m} + + ))} + {a.enabled_modules.length === 0 && ( + модулі не активовані + )} +
    + + {canWrite && ( +
    + + +
    + )} +
    +
  • + ))} +
+ )} + + )} + + + {enrolling && ( + setEnrolling(false)} onCreated={reload} /> + )} + + {editing && ( + setEditing(null)} + onSaved={() => { + setEditing(null) + reload() + }} + /> + )} + + setConfirm(null)} /> ) } +/** + * Налаштування зонда: імʼя, модулі, ліміти. + * + * Зміни доїжджають не миттєво — модулі й ліміти їдуть у Welcome при + * наступному підключенні. Форма каже це прямим текстом: мовчазна + * затримка виглядає як «не зберіглося». + */ +function AgentForm({ + agent, + onClose, + onSaved, +}: { + agent: AgentSummary + onClose: () => void + onSaved: () => void +}) { + const [name, setName] = useState(agent.name) + const [modules, setModules] = useState(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(null) + + return ( + +
+ + setName(e.target.value)} + /> + + + +
+ {MODULES.map((m) => { + const on = modules.includes(m) + return ( + + ) + })} +
+
+ +
+ + setConcurrency(e.target.value)} + /> + + + setIcmpRate(e.target.value)} + /> + +
+ +

+ Модулі й ліміти доїдуть до зонда при наступному підключенні. Перелік перевірок — + швидше, звіркою плану раз на кілька секунд. +

+ + {err} + +
+ + +
+
+
+ ) +} + function Pair({ k, v }: { k: string; v: string }) { return (
diff --git a/web/src/types.ts b/web/src/types.ts index 0466d30..4eb1328 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -298,6 +298,22 @@ export interface AgentSummary { enabled_modules: string[] health: Record device_count: number + limits?: Record +} + +/** Одноразове запрошення на реєстрацію зонда. */ +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 } // ---------------------------------------------------------------------