Files

392 lines
14 KiB
Go

// Package configserver — доменный слой MCP-модуля omnichannel-configserver-mcp.
//
// Здесь нет зависимостей от MCP: только разбор конфига, авторизованные
// HTTP-сессии к API config_server и типизированные вызовы его эндпоинтов.
// MCP-слой (internal/tools) — тонкая обёртка, которая парсит аргументы,
// дёргает этот пакет и форматирует результат.
package configserver
import (
"encoding/json"
"fmt"
"net/url"
"os"
"regexp"
"sort"
"strings"
"git.totmin.ru/en2zmax/forge-toolkit"
)
// Значения по умолчанию для лимитов сервера. Все они задаются в config.json,
// но модуль должен работать и с минимальным конфигом (только base_url).
const (
DefaultTimeoutSec = 30
DefaultUploadTimeoutSec = 300
DefaultTaskPollMaxSec = 120
DefaultTaskPollIntervalSec = 2
DefaultMaxOutputBytes = 100_000
DefaultMaxUploadBytes int64 = 512 << 20 // 512 MiB
DefaultMaxConcurrentMutations = 1
)
// Server — одно подключение к config_server (стенд/контур).
type Server struct {
Alias string `json:"alias"`
// BaseURL — адрес API, например http://10.101.60.3:5005.
BaseURL string `json:"base_url"`
// Username/Password — учётная запись с правом записи (используется
// изменяющими инструментами).
Username string `json:"username"`
Password string `json:"password"`
// ReadonlyUsername/ReadonlyPassword — необязательная учётная запись только
// для чтения. Если задана, read-инструменты используют её (least-privilege
// на нашей стороне; см. ARCHITECTURE.md §9.5).
ReadonlyUsername string `json:"readonly_username"`
ReadonlyPassword string `json:"readonly_password"`
// GitlabToken — токен для /api/deployment/download_release. Секрет: держим
// в env/локальном конфиге, никогда не логируем.
GitlabToken string `json:"gitlab_token"`
// InsecureSkipVerify отключает проверку TLS-сертификата (для стендов с
// самоподписанными сертификатами).
InsecureSkipVerify bool `json:"insecure_skip_verify"`
TimeoutSec int `json:"timeout_sec"`
UploadTimeoutSec int `json:"upload_timeout_sec"`
TaskPollMaxSec int `json:"task_poll_max_sec"`
TaskPollIntervalSec int `json:"task_poll_interval_sec"`
MaxOutputBytes int `json:"max_output_bytes"`
MaxUploadBytes int64 `json:"max_upload_bytes"`
MaxConcurrentMutations int `json:"max_concurrent_mutations"`
// FrontRoots — разрешённые каталоги локальных сборок фронта для
// update_front (jail). Пусто — загрузка фронта запрещена (fail-closed).
FrontRoots []string `json:"front_roots"`
}
// Config — корневой конфиг модуля (config.json или per-agent omnichannel-configserver-mcp.json).
type Config struct {
Servers []Server `json:"servers"`
Default string `json:"default"`
ReadOnly bool `json:"read_only"`
// AllowHosts — необязательный allowlist хостов. Если задан, base_url любого
// сервера обязан быть на одном из этих хостов (защита от опечаток/SSRF).
// Пусто — доверяем хостам из самих серверов.
AllowHosts []string `json:"allow_hosts"`
}
// rawConfig — форма для строгого разбора: позволяет отличить «read_only не
// задан» (тогда безопасный дефолт true) от явного false.
type rawConfig struct {
Servers []Server `json:"servers"`
Default string `json:"default"`
ReadOnly *bool `json:"read_only"`
AllowHosts []string `json:"allow_hosts"`
// Single-server shorthand: если servers пуст, а base_url задан — считаем это
// одним сервером с алиасом "default".
BaseURL string `json:"base_url"`
Username string `json:"username"`
Password string `json:"password"`
}
var envVarRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
// ParseConfig разбирает конфиг: раскрывает ${VAR} из окружения, применяет
// дефолты и валидирует. Fail-closed: незаполненная переменная, битый адрес или
// отсутствие серверов — ошибка, а не «подозрительный дефолт».
func ParseConfig(data []byte) (*Config, error) {
if unresolved := unresolvedVars(data); len(unresolved) > 0 {
return nil, fmt.Errorf("не заданы переменные окружения: %s", strings.Join(unresolved, ", "))
}
return decodeConfig(toolkit.Expand(data))
}
// decodeConfig разбирает уже раскрытый JSON (без ${VAR}).
func decodeConfig(data []byte) (*Config, error) {
var raw rawConfig
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(&raw); err != nil {
return nil, fmt.Errorf("разбор config.json: %w", err)
}
cfg := &Config{
Servers: raw.Servers,
Default: strings.TrimSpace(raw.Default),
ReadOnly: true,
AllowHosts: raw.AllowHosts,
}
if raw.ReadOnly != nil {
cfg.ReadOnly = *raw.ReadOnly
}
if len(cfg.Servers) == 0 && strings.TrimSpace(raw.BaseURL) != "" {
cfg.Servers = []Server{{Alias: "default", BaseURL: raw.BaseURL, Username: raw.Username, Password: raw.Password}}
}
if err := cfg.normalize(); err != nil {
return nil, err
}
return cfg, nil
}
// LocalConfigSuffix — суффикс локального файла с секретами (gitignored).
const LocalConfigSuffix = ".local"
// LoadFile загружает конфиг из основного файла, накладывая поверх соседний
// <name>.local.json (реальные секреты; в репозитории — только шаблон).
func LoadFile(path string) (*Config, error) {
mainData, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if unresolved := unresolvedVars(mainData); len(unresolved) > 0 {
return nil, fmt.Errorf("не заданы переменные окружения: %s", strings.Join(unresolved, ", "))
}
expanded := toolkit.Expand(mainData)
if localPath := localConfigPath(path); localPath != "" {
if localData, err := os.ReadFile(localPath); err == nil {
if unresolved := unresolvedVars(localData); len(unresolved) > 0 {
return nil, fmt.Errorf("не заданы переменные окружения (в %s): %s", localPath, strings.Join(unresolved, ", "))
}
if expanded, err = mergeConfigJSON(expanded, toolkit.Expand(localData)); err != nil {
return nil, fmt.Errorf("слияние %s: %w", localPath, err)
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("чтение %s: %w", localPath, err)
}
}
return decodeConfig(expanded)
}
// localConfigPath возвращает путь <dir>/<name>.local.json для основного файла.
func localConfigPath(path string) string {
if strings.HasSuffix(path, ".json") {
return strings.TrimSuffix(path, ".json") + LocalConfigSuffix + ".json"
}
return path + LocalConfigSuffix
}
// mergeConfigJSON накладывает over поверх base: объекты сливаются рекурсивно,
// массивы servers — по alias (локальный сервер дополняет/перезаписывает
// одноимённый), прочие значения over перезаписывают base.
func mergeConfigJSON(base, over []byte) ([]byte, error) {
var baseMap, overMap map[string]any
if err := json.Unmarshal(base, &baseMap); err != nil {
return nil, err
}
if err := json.Unmarshal(over, &overMap); err != nil {
return nil, err
}
merged := mergeMaps(baseMap, overMap)
return json.Marshal(merged)
}
// mergeMaps рекурсивно сливает карты; список servers обрабатывается отдельно.
func mergeMaps(base, over map[string]any) map[string]any {
out := make(map[string]any, len(base)+len(over))
for k, v := range base {
out[k] = v
}
for k, v := range over {
if k == "servers" {
out[k] = mergeServers(out[k], v)
continue
}
if baseChild, ok := out[k].(map[string]any); ok {
if overChild, ok := v.(map[string]any); ok {
out[k] = mergeMaps(baseChild, overChild)
continue
}
}
out[k] = v
}
return out
}
// mergeServers сливает массивы серверов по полю alias.
func mergeServers(base, over any) []any {
baseList, _ := base.([]any)
overList, _ := over.([]any)
index := map[string]int{}
var result []any
for _, item := range baseList {
m, _ := item.(map[string]any)
alias, _ := m["alias"].(string)
index[alias] = len(result)
result = append(result, m)
}
for _, item := range overList {
m, _ := item.(map[string]any)
alias, _ := m["alias"].(string)
if pos, ok := index[alias]; ok {
if baseServer, ok := result[pos].(map[string]any); ok {
result[pos] = mergeMaps(baseServer, m)
continue
}
}
index[alias] = len(result)
result = append(result, m)
}
return result
}
// unresolvedVars возвращает имена переменных ${VAR}, которых нет в окружении.
func unresolvedVars(data []byte) []string {
seen := map[string]bool{}
var out []string
for _, m := range envVarRe.FindAllSubmatch(data, -1) {
name := string(m[1])
if seen[name] {
continue
}
seen[name] = true
if os.Getenv(name) == "" {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// normalize применяет дефолты и валидирует конфиг.
func (c *Config) normalize() error {
if len(c.Servers) == 0 {
return fmt.Errorf("не задано ни одного сервера (servers или base_url)")
}
aliases := map[string]bool{}
for i := range c.Servers {
s := &c.Servers[i]
if strings.TrimSpace(s.Alias) == "" {
return fmt.Errorf("servers[%d]: не задан alias", i)
}
if aliases[s.Alias] {
return fmt.Errorf("дублирующийся alias %q", s.Alias)
}
aliases[s.Alias] = true
if err := c.validateServer(s); err != nil {
return fmt.Errorf("сервер %q: %w", s.Alias, err)
}
}
// default: явный или первый сервер.
if c.Default == "" {
c.Default = c.Servers[0].Alias
}
if !aliases[c.Default] {
return fmt.Errorf("default %q не найден среди серверов (%s)", c.Default, strings.Join(c.ServerNames(), ", "))
}
return nil
}
// validateServer проверяет адрес, allowlist и заполняет лимиты дефолтами.
func (c *Config) validateServer(s *Server) error {
u, err := url.Parse(strings.TrimSpace(s.BaseURL))
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("некорректный base_url %q", s.BaseURL)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("base_url должен быть http/https, получено %q", u.Scheme)
}
if len(c.AllowHosts) > 0 && !containsFold(c.AllowHosts, u.Hostname()) {
return fmt.Errorf("хост %q не в allow_hosts (%s)", u.Hostname(), strings.Join(c.AllowHosts, ", "))
}
s.BaseURL = strings.TrimRight(s.BaseURL, "/")
s.TimeoutSec = orDefault(s.TimeoutSec, DefaultTimeoutSec)
s.UploadTimeoutSec = orDefault(s.UploadTimeoutSec, DefaultUploadTimeoutSec)
s.TaskPollMaxSec = orDefault(s.TaskPollMaxSec, DefaultTaskPollMaxSec)
s.TaskPollIntervalSec = orDefault(s.TaskPollIntervalSec, DefaultTaskPollIntervalSec)
s.MaxOutputBytes = orDefault(s.MaxOutputBytes, DefaultMaxOutputBytes)
s.MaxConcurrentMutations = orDefault(s.MaxConcurrentMutations, DefaultMaxConcurrentMutations)
if s.MaxUploadBytes <= 0 {
s.MaxUploadBytes = DefaultMaxUploadBytes
}
return nil
}
// Server возвращает сервер по алиасу (пустой — default).
func (c *Config) Server(alias string) (*Server, error) {
if alias == "" {
alias = c.Default
}
for i := range c.Servers {
if c.Servers[i].Alias == alias {
return &c.Servers[i], nil
}
}
return nil, fmt.Errorf("неизвестный сервер %q; настроены: %s (default: %s)",
alias, strings.Join(c.ServerNames(), ", "), c.Default)
}
// ServerNames возвращает список алиасов (для сообщений об ошибках).
func (c *Config) ServerNames() []string {
out := make([]string, 0, len(c.Servers))
for _, s := range c.Servers {
out = append(out, s.Alias)
}
return out
}
// Redacted возвращает копию конфига с замаскированными секретами — для
// `--check-config` и диагностики. Пароли/токены не покидают процесс.
func (c *Config) Redacted() map[string]any {
servers := make([]map[string]any, 0, len(c.Servers))
for _, s := range c.Servers {
servers = append(servers, map[string]any{
"alias": s.Alias,
"base_url": s.BaseURL,
"username": s.Username,
"password": mask(s.Password),
"readonly_username": s.ReadonlyUsername,
"readonly_password": mask(s.ReadonlyPassword),
"gitlab_token": mask(s.GitlabToken),
"insecure_skip_verify": s.InsecureSkipVerify,
"timeout_sec": s.TimeoutSec,
"upload_timeout_sec": s.UploadTimeoutSec,
"task_poll_max_sec": s.TaskPollMaxSec,
"task_poll_interval_sec": s.TaskPollIntervalSec,
"max_output_bytes": s.MaxOutputBytes,
"max_upload_bytes": s.MaxUploadBytes,
"max_concurrent_mutations": s.MaxConcurrentMutations,
"front_roots": s.FrontRoots,
})
}
return map[string]any{
"servers": servers,
"default": c.Default,
"read_only": c.ReadOnly,
"allow_hosts": c.AllowHosts,
}
}
func orDefault(v, def int) int {
if v <= 0 {
return def
}
return v
}
func mask(secret string) string {
if secret == "" {
return ""
}
return "***"
}
func containsFold(list []string, value string) bool {
for _, item := range list {
if strings.EqualFold(item, value) {
return true
}
}
return false
}