Files

172 lines
5.9 KiB
Go

// Package ssh: per-tenant политика подключений (контракт PLAN.md
// "Per-tenant forge-tools"). Файл <FORGE_TENANT_CONFIG>/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 читает <path>/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
}