Етап 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:
byrsapty 2026-08-25 00:50:57 +03:00
parent edc6209c98
commit ddaae60fa0
25 changed files with 1549 additions and 102 deletions

View file

@ -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, значення застосовані
```

View file

@ -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 (мобільний)** — після того, як є що показувати.

View file

@ -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 для локального стенду)")
} }

View 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
}

View 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;

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -56,6 +56,15 @@ message EnrollResponse {
// Куди підключатись далі (може відрізнятись від адреси реєстрації: // Куди підключатись далі (може відрізнятись від адреси реєстрації:
// балансувальник, регіональний шлюз). // балансувальник, регіональний шлюз).
string control_endpoint = 6; string control_endpoint = 6;
// Постійний токен зонда: визначає, ЯКИЙ це зонд. Видається рівно
// один раз далі агент зберігає його сам.
//
// Окреме поле, а не certificate: сертифікат відповідає на інше
// питання («чи має право говорити з сервером») і живе за іншим
// життєвим циклом. Складати два різні секрети в одне поле означає
// зафіксувати проміжний етап у протоколі назавжди.
string agent_token = 7;
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------

View file

@ -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` і файл посвідчення.
Якщо запис не вдався, повідомлення несе сам токен: інакше довелося б
створювати нове запрошення лише через те, що каталог виявився
недоступним.
### Історія метрик ### Історія метрик
| Метод | Шлях | Призначення | | Метод | Шлях | Призначення |

View 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
}

View file

@ -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

View 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)
}

View file

@ -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))

View file

@ -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
})
}

View 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
}

View file

@ -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()

View file

@ -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' }),
} }

View 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>
)
}

View file

@ -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">

View file

@ -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
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------