// 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 загружает конфиг из основного файла, накладывая поверх соседний // .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 возвращает путь /.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 }