Netpulse_SasS/agent/internal/config/config.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

180 lines
6.9 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 — налаштування агента з прапорців і змінних оточення.
//
// Конфіг-файлу навмисно немає: у зонда рівно жменя параметрів, а
// відсутність YAML економить залежність і кілька сотень кілобайт
// бінарника. Усе інше агент отримує від сервера в Welcome і ModuleControl.
package config
import (
"crypto/tls"
"crypto/x509"
"errors"
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
// Куди підключатись: host:port.
Endpoint string
AgentID string
Hostname string
// Токен зонда, виданий при Enroll. Визначає, ЯКИЙ це зонд;
// сертифікат mTLS — що він узагалі має право говорити з сервером.
Token string
// mTLS. Порожні шляхи + Insecure = локальний стенд без шифрування.
CertFile string
KeyFile string
CAFile string
Insecure bool
MaxConcurrency int
BufferMaxItems int
BufferMaxBytes int
MinBackoff time.Duration
MaxBackoff time.Duration
LogLevel string
LogJSON bool
// Модулі, увімкнені до першої відповіді сервера.
DefaultModules []string
// Одноразовий токен запрошення. Якщо заданий — зонд спершу
// реєструється, зберігає посвідчення й лише потім працює.
EnrollToken string
// Бажане ім'я зонда при реєстрації.
EnrollName string
// Де лежить посвідчення між запусками.
IdentityPath string
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
// Parse читає прапорці поверх змінних оточення (NETPULSE_*).
func Parse(args []string) (*Config, error) {
host, _ := os.Hostname()
c := &Config{}
fs := flag.NewFlagSet("netpulse-agent", flag.ContinueOnError)
fs.StringVar(&c.Endpoint, "server", envOr("NETPULSE_SERVER", ""), "адреса сервера, host:port")
fs.StringVar(&c.AgentID, "agent-id", envOr("NETPULSE_AGENT_ID", ""), "ідентифікатор зонда")
fs.StringVar(&c.Hostname, "hostname", envOr("NETPULSE_HOSTNAME", host), "ім'я хоста для журналу сервера")
fs.StringVar(&c.Token, "token", envOr("NETPULSE_TOKEN", ""), "токен зонда з Enroll")
fs.StringVar(&c.CertFile, "cert", envOr("NETPULSE_CERT", ""), "клієнтський сертифікат (mTLS)")
fs.StringVar(&c.KeyFile, "key", envOr("NETPULSE_KEY", ""), "приватний ключ")
fs.StringVar(&c.CAFile, "ca", envOr("NETPULSE_CA", ""), "CA сервера")
fs.BoolVar(&c.Insecure, "insecure", os.Getenv("NETPULSE_INSECURE") == "1",
"без TLS — лише для локального стенду")
fs.IntVar(&c.MaxConcurrency, "max-concurrency", envInt("NETPULSE_MAX_CONCURRENCY", 64),
"скільки чеків виконувати паралельно")
fs.IntVar(&c.BufferMaxItems, "buffer-items", envInt("NETPULSE_BUFFER_ITEMS", 50_000),
"ліміт записів у буфері телеметрії")
fs.IntVar(&c.BufferMaxBytes, "buffer-bytes", envInt("NETPULSE_BUFFER_BYTES", 8<<20),
"оціночний ліміт пам'яті буфера в байтах")
fs.DurationVar(&c.MinBackoff, "min-backoff", time.Second, "мінімальна пауза перед реконектом")
fs.DurationVar(&c.MaxBackoff, "max-backoff", 2*time.Minute, "максимальна пауза перед реконектом")
fs.StringVar(&c.LogLevel, "log-level", envOr("NETPULSE_LOG_LEVEL", "info"), "debug|info|warn|error")
fs.BoolVar(&c.LogJSON, "log-json", os.Getenv("NETPULSE_LOG_JSON") == "1", "журнал у JSON")
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
}
for _, m := range strings.Split(*mods, ",") {
if m = strings.TrimSpace(m); m != "" {
c.DefaultModules = append(c.DefaultModules, m)
}
}
return c, c.validate()
}
func (c *Config) validate() error {
if c.Endpoint == "" {
return errors.New("не вказано адресу сервера (-server або NETPULSE_SERVER)")
}
// Ідентичність тут не перевіряємо взагалі.
//
// До реєстрації її ще немає, після — вона приходить із файлу
// посвідчення, про який validate не знає. Перевірка живе в main,
// коли всі три джерела (прапорці, файл, реєстрація) вже враховані:
// інакше запуск із самим -identity падав із «зонд не
// зареєстрований», хоча посвідчення лежало поруч.
if !c.Insecure && (c.CertFile == "" || c.KeyFile == "") {
return errors.New("потрібні -cert і -key для mTLS (або -insecure для локального стенду)")
}
if c.MaxConcurrency <= 0 {
return errors.New("-max-concurrency має бути додатним")
}
return nil
}
// TLSConfig збирає mTLS-конфігурацію.
//
// Перевірка сертифіката сервера обов'язкова й не вимикається прапорцем:
// зонд ходить через інтернет, і «тимчасово без перевірки» — це той
// самий канал, яким їдуть паролі від усіх комутаторів клієнта.
func (c *Config) TLSConfig() (*tls.Config, error) {
if c.Insecure {
return nil, nil
}
cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile)
if err != nil {
return nil, fmt.Errorf("клієнтський сертифікат: %w", err)
}
tc := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13,
}
if c.CAFile != "" {
pem, err := os.ReadFile(c.CAFile)
if err != nil {
return nil, fmt.Errorf("CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, errors.New("CA: не вдалося розібрати PEM")
}
tc.RootCAs = pool
}
return tc, nil
}