Initial commit: forge-tools-ssh — MCP-сервер для администрирования по SSH
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user