392 lines
14 KiB
Go
392 lines
14 KiB
Go
// Package configserver — доменный слой MCP-модуля omnichannel-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-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
|
|
}
|