// Package ssh: per-tenant политика подключений (контракт PLAN.md // "Per-tenant forge-tools"). Файл /ssh.json декларирует // именованные профили, на которые агент ссылается по алиасу, и allowlist // хостов, валидирующий ad-hoc-подключения. Цель - "платформа объявляет, // модель ссылается": LLM не должен сам выбирать host/ключ; это делает // оператор, а агент выбирает только из разрешённого. package ssh import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "strings" "git.totmin.ru/en2zmax/forge-toolkit" ) // Profile - одно именованное подключение, на которое агент ссылается по // Alias. Значения Host/Username/KeyPath задаёт оператор; модель их не вводит. type Profile struct { Alias string `json:"alias"` Host string `json:"host"` Username string `json:"username"` Port int `json:"port"` // KeyPath - private key для подключения; может быть ${VAR} или пустым // (тогда берётся DefaultKeyPath либо системный ключ KeyManager). KeyPath string `json:"key"` // Via - jump-host алиас для туннелирования; Target - PAM-шлюз. Via string `json:"via"` Target string `json:"target"` TargetPass string `json:"target_password"` } // Policy - декларативная per-agent политика подключений. type Policy struct { Profiles []Profile `json:"profiles"` // AllowedHosts - glob-паттерны разрешённых хостов ("10.0.*", // "*.internal"). Пустой список = allow all (обратная совместимость с // ad-hoc режимом без конфига). Наличие списка включает жалостную // проверку: host вне списка - deny. AllowedHosts []string `json:"allowed_hosts"` // DefaultKeyPath - ключ по умолчанию, если ни профиль, ни вызов не // указали private_key_path. Пусто = системный ключ (SSH_MCP_KEY_PATH). DefaultKeyPath string `json:"default_key_path"` byAlias map[string]Profile hostGlob []*regexp.Regexp } // LoadPolicy читает /ssh.json, раскрывает ${VAR} и валидирует. // Отсутствие файла - nil, nil (политика не настроена -> ad-hoc без // ограничений). Битый файл - ошибка (fail-closed). func LoadPolicy(path string) (*Policy, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("read ssh policy %s: %w", path, err) } return parsePolicy(data) } // parsePolicy разбирает готовые байты ssh.json (${VAR} + валидация). // Используется и LoadPolicy, и configreload.Loader (live-reload, см. // manager.go): loader передаёт сырые байты файла на каждый Get. func parsePolicy(data []byte) (*Policy, error) { p := &Policy{} if err := json.Unmarshal(toolkit.Expand(data), p); err != nil { return nil, fmt.Errorf("parse ssh policy: %w", err) } if err := p.normalize(); err != nil { return nil, fmt.Errorf("ssh policy: %w", err) } return p, nil } func (p *Policy) normalize() error { p.byAlias = make(map[string]Profile, len(p.Profiles)) for _, pr := range p.Profiles { if pr.Alias == "" || pr.Host == "" || pr.Username == "" { return fmt.Errorf("profile must have alias, host and username") } if _, dup := p.byAlias[pr.Alias]; dup { return fmt.Errorf("duplicate profile alias %q", pr.Alias) } p.byAlias[pr.Alias] = pr } for _, h := range p.AllowedHosts { re, err := globToRegex(h) if err != nil { return fmt.Errorf("allowed_hosts pattern %q: %w", h, err) } p.hostGlob = append(p.hostGlob, re) } return nil } // Resolve возвращает профиль по алиасу. func (p *Policy) Resolve(alias string) (Profile, bool) { if p == nil { return Profile{}, false } pr, ok := p.byAlias[alias] return pr, ok } // Allow сообщает, разрешён ли хост. Без allowed_hosts - всегда true // (ad-hoc сохранён как раньше); иначе host обязан матчить хоть один glob. func (p *Policy) Allow(host string) bool { if p == nil || len(p.hostGlob) == 0 { return true } for _, re := range p.hostGlob { if re.MatchString(host) { return true } } return false } // KeyPath возвращает эффективный путь к ключу для профиля: из профиля, // иначе DefaultKeyPath, иначе "" (системный ключ KeyManager). func (p *Policy) KeyPath(pr Profile) string { if pr.KeyPath != "" { return pr.KeyPath } if p != nil { return p.DefaultKeyPath } return "" } // globToRegex превращает glob-паттерн хоста в regex с полным совпадением. func globToRegex(pattern string) (*regexp.Regexp, error) { re, err := regexp.Compile("^" + globToRe(pattern) + "$") if err != nil { return nil, err } return re, nil } func globToRe(pattern string) string { var b strings.Builder prev := rune(0) for _, ch := range pattern { switch ch { case '*': if prev != '*' { b.WriteString(".*") } case '?': b.WriteByte('.') default: b.WriteString(regexp.QuoteMeta(string(ch))) } prev = ch } return b.String() } // PolicyPathFromEnv - путь к per-agent ssh.json из FORGE_TENANT_CONFIG. func PolicyPathFromEnv() (string, bool) { dir := os.Getenv("FORGE_TENANT_CONFIG") if dir == "" { return "", false } return filepath.Join(dir, "ssh.json"), true }