172 lines
5.9 KiB
Go
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
|
|
}
|