Найбільше вузьке місце до запуску: агент заводився 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>
139 lines
4.4 KiB
Go
139 lines
4.4 KiB
Go
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
|
||
}
|