Netpulse_SasS/agent/internal/config/enroll.go
byrsapty ddaae60fa0 Етап 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>
2026-08-25 00:50:57 +03:00

139 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}