Initial commit: forge-tools-ssh — MCP-сервер для администрирования по SSH

This commit is contained in:
Maksim Totmin
2026-10-01 10:13:48 +07:00
commit 1821fe7968
30 changed files with 5342 additions and 0 deletions
+710
View File
@@ -0,0 +1,710 @@
package ssh
import (
"context"
"errors"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
"golang.org/x/crypto/ssh"
)
// Manager manages multiple SSH connections for a single session.
type Manager struct {
connections map[string]*Client
primary string
keyManager *KeyManager
// policy - текущая (закэшированная) per-agent политика подключений.
// nil = политика не настроена: ad-hoc режим как раньше (без ограничений).
policy *Policy
// policyLoader - live-reload политики из <FORGE_TENANT_CONFIG>/ssh.json
// (см. configreload). nil, если FORGE_TENANT_CONFIG не задан (легаси) или
// задана статическая политика через SetPolicy (тесты).
policyLoader *configreload.Loader[*Policy]
mu sync.RWMutex
aliasLocks map[string]*sync.Mutex
reconnectFails map[string]time.Time // alias -> last failed reconnect time
dockerCache map[string]*bool // alias -> docker available (nil = unknown)
}
// NewManager creates a new SSH connection manager. Если задан
// FORGE_TENANT_CONFIG/ssh.json - политика подхватывается через live-reload
// (configreload по контент-хэшу): правки ssh.json применяются к следующему
// Connect без рестарта.
func NewManager(keyPath string) *Manager {
mgr := &Manager{
connections: make(map[string]*Client),
keyManager: NewKeyManager(keyPath),
aliasLocks: make(map[string]*sync.Mutex),
reconnectFails: make(map[string]time.Time),
dockerCache: make(map[string]*bool),
}
if err := mgr.keyManager.EnsureKey(); err != nil {
log.Printf("manager: key setup warning: %v", err)
}
if path, ok := PolicyPathFromEnv(); ok {
mgr.policyLoader = configreload.New(path, parsePolicy)
}
return mgr
}
// SetPolicy задаёт per-agent политику подключений вручную (тесты).
// Отключает live-reload (loader == nil), чтобы тесты были детерминированы.
func (m *Manager) SetPolicy(p *Policy) {
_ = p.normalize()
m.policy = p
m.policyLoader = nil
}
// currentPolicy возвращает актуальную политику, перечитав её при изменении
// содержимого ssh.json (live-reload). Нет loader'а → статическая policy.
// Ошибки: ErrNotFound → (nil, err) (нет политики = ad-hoc без ограничений);
// парсинг-ошибка → (lastGood, err) (сохраняем рабочую, но сообщаем).
func (m *Manager) currentPolicy() (*Policy, error) {
if m.policyLoader == nil {
return m.policy, nil
}
return m.policyLoader.Get()
}
// getAliasLock returns a per-alias lock.
func (m *Manager) getAliasLock(alias string) *sync.Mutex {
m.mu.Lock()
defer m.mu.Unlock()
if lock, ok := m.aliasLocks[alias]; ok {
return lock
}
lock := &sync.Mutex{}
m.aliasLocks[alias] = lock
return lock
}
// generateAlias creates a unique alias and reserves it.
func (m *Manager) generateAlias(username, host string) string {
base := fmt.Sprintf("%s@%s", username, host)
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.connections[base]; !exists {
m.connections[base] = nil // Reserve
return base
}
for i := 2; i < 100; i++ {
candidate := fmt.Sprintf("%s-%d", base, i)
if _, exists := m.connections[candidate]; !exists {
m.connections[candidate] = nil // Reserve
return candidate
}
}
// Fallback (might collide but highly unlikely to fill 100 slots)
final := fmt.Sprintf("%s-%d", base, 100)
m.connections[final] = nil
return final
}
// ConnectOptions contains options for SSH connection.
type ConnectOptions struct {
Host string
Port int
Username string
Password string
PrivateKeyPath string
Alias string
Via string
// Target is an optional destination host routed through a PAM gateway
// (SafeInspect). When set, the gateway receives "Username@Target" and
// TargetPassword is used as the second authentication stage.
Target string
TargetPassword string
// Profile - имя профиля из ssh.json (см. policy.go). Если задан, креды
// (Host/Username/Port/Key/Via/Target) берутся из политики, а не от
// модели. Модель выбирает только имя; адрес и ключ задаёт оператор.
Profile string
}
// Connect establishes an SSH connection and returns the alias. Применяет
// per-agent политику (см. policy.go): профиль резолвит креды, а host
// обязан пройти allowlist. Модель не может подключиться вне разрешённого.
func (m *Manager) Connect(ctx context.Context, opts ConnectOptions) (alias string, err error) {
if opts.Port == 0 {
opts.Port = 22
}
// Live-reload политики: перечитываем ssh.json по контент-хэшу.
// ErrNotFound → нет политики (ad-hoc без ограничений). Парсинг-ошибка →
// last-good (если был) + предупреждение; без last-good — fail-closed.
pol, perr := m.currentPolicy()
if perr != nil {
if !errors.Is(perr, configreload.ErrNotFound) && pol == nil {
return "", fmt.Errorf("ssh: per-agent policy unavailable: %w", perr)
}
log.Printf("ssh: policy warning: %v", perr)
}
// Резолв профиля: платформа объявляет креды, модель выбирает по имени.
if opts.Profile != "" {
if pol == nil {
return "", errors.New("ssh: profile requested but no per-agent policy configured")
}
pr, ok := pol.Resolve(opts.Profile)
if !ok {
return "", fmt.Errorf("ssh: profile %q not found in policy", opts.Profile)
}
opts.Host = pr.Host
opts.Username = pr.Username
if pr.Port != 0 {
opts.Port = pr.Port
}
if opts.PrivateKeyPath == "" {
opts.PrivateKeyPath = pr.KeyPath
}
if opts.Via == "" {
opts.Via = pr.Via
}
if opts.Target == "" {
opts.Target = pr.Target
if opts.TargetPassword == "" {
opts.TargetPassword = pr.TargetPass
}
}
}
// Креды обязательны (профиль дал их, либо модель прислала host+username).
if opts.Host == "" || opts.Username == "" {
return "", errors.New("ssh: host and username are required")
}
// Allowlist: вне списка - deny (fail-closed). Пустой список = allow all.
if pol != nil && !pol.Allow(opts.Host) {
return "", fmt.Errorf("ssh: host %q is not in allowed_hosts of per-agent policy", opts.Host)
}
// Default key: если ни профиль, ни вызов не задали ключ - берём
// DefaultKeyPath политики, иначе "" (системный ключ KeyManager).
if pol != nil && opts.PrivateKeyPath == "" {
opts.PrivateKeyPath = pol.DefaultKeyPath
}
var reserved bool
if opts.Alias == "" {
// Include the PAM target (if any) so distinct targets routed through
// the same gateway get distinct auto-generated aliases.
aliasBase := opts.Username
if opts.Target != "" {
aliasBase = opts.Username + "@" + opts.Target
}
opts.Alias = m.generateAlias(aliasBase, opts.Host)
reserved = true
}
if opts.Via == opts.Alias {
return "", errors.New("'via' cannot be the same as 'alias'")
}
m.mu.Lock()
existing, exists := m.connections[opts.Alias]
if exists {
if existing != nil {
m.mu.Unlock()
if existing.creds.Host == opts.Host && existing.creds.Username == opts.Username && existing.creds.Target == opts.Target {
return opts.Alias, nil
}
return "", fmt.Errorf("alias '%s' already exists for %s@%s", opts.Alias, existing.creds.Username, existing.creds.Host)
}
// Existing is nil (reserved)
if !reserved {
m.mu.Unlock()
return "", fmt.Errorf("alias '%s' is currently connecting/reserved", opts.Alias)
}
// It's our reservation, proceed
} else {
// New explicit alias
m.connections[opts.Alias] = nil // Reserve
}
m.mu.Unlock()
// Defer cleanup of reservation on error
defer func() {
if err != nil {
m.mu.Lock()
// Only remove if it's still nil (failed to connect)
if c, ok := m.connections[opts.Alias]; ok && c == nil {
delete(m.connections, opts.Alias)
}
m.mu.Unlock()
}
}()
creds := Credentials{
Host: opts.Host,
Port: opts.Port,
Username: opts.Username,
Password: opts.Password,
Via: opts.Via,
Target: opts.Target,
TargetPassword: opts.TargetPassword,
}
if opts.PrivateKeyPath != "" {
keyBytes, err := os.ReadFile(opts.PrivateKeyPath)
if err != nil {
return "", fmt.Errorf("failed to read private key: %w", err)
}
signer, err := ssh.ParsePrivateKey(keyBytes)
if err != nil {
return "", fmt.Errorf("failed to parse private key: %w", err)
}
creds.PrivateKey = signer
} else if opts.Password == "" {
signer, err := m.keyManager.LoadPrivateKey()
if err != nil {
return "", fmt.Errorf("no auth provided and system key unavailable: %w", err)
}
creds.PrivateKey = signer
log.Printf("ssh: using system key for %s", opts.Alias)
}
var jumpClient *Client
if opts.Via != "" {
m.mu.RLock()
jumpClient = m.connections[opts.Via]
m.mu.RUnlock()
if jumpClient == nil {
return "", fmt.Errorf("jump host '%s' not connected", opts.Via)
}
}
client, err := NewClient(ctx, opts.Alias, creds, jumpClient)
if err != nil {
return "", err
}
m.mu.Lock()
m.connections[opts.Alias] = client
if m.primary == "" {
m.primary = opts.Alias
}
m.mu.Unlock()
return opts.Alias, nil
}
// Disconnect closes one or all connections.
func (m *Manager) Disconnect(alias string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if alias == "" {
count := 0
for a, client := range m.connections {
if client != nil {
client.Close()
count++
}
delete(m.connections, a)
delete(m.aliasLocks, a)
delete(m.reconnectFails, a)
delete(m.dockerCache, a)
}
m.primary = ""
return fmt.Sprintf("Disconnected all (%d) connections", count), nil
}
client, ok := m.connections[alias]
if !ok {
return "", fmt.Errorf("no connection with alias '%s'", alias)
}
if client != nil {
client.Close()
}
delete(m.connections, alias)
delete(m.aliasLocks, alias)
delete(m.reconnectFails, alias)
delete(m.dockerCache, alias)
if m.primary == alias {
m.primary = ""
for a, c := range m.connections {
if c != nil {
m.primary = a
break
}
}
}
return fmt.Sprintf("Disconnected '%s'", alias), nil
}
// resolveTarget returns the target alias.
func (m *Manager) resolveTarget(target string) (string, error) {
if target != "" && target != "primary" {
m.mu.RLock()
_, ok := m.connections[target]
m.mu.RUnlock()
if !ok {
return "", fmt.Errorf("no connection with alias '%s'", target)
}
return target, nil
}
m.mu.RLock()
primary := m.primary
m.mu.RUnlock()
if primary == "" {
return "", errors.New("no active connection")
}
return primary, nil
}
// Run executes a command on the target connection.
func (m *Manager) Run(ctx context.Context, cmd, target string) (*RunResult, error) {
alias, err := m.resolveTarget(target)
if err != nil {
return nil, err
}
lock := m.getAliasLock(alias)
lock.Lock()
defer lock.Unlock()
m.mu.RLock()
client := m.connections[alias]
m.mu.RUnlock()
if client == nil {
return nil, fmt.Errorf("connection '%s' not found", alias)
}
result, err := client.Run(ctx, cmd)
if err != nil {
if isConnectionError(err) {
// Check reconnect backoff
m.mu.RLock()
lastFail := m.reconnectFails[alias]
m.mu.RUnlock()
if time.Since(lastFail) < 5*time.Second {
return nil, fmt.Errorf("connection lost (reconnect backoff): %w", err)
}
log.Printf("ssh: connection lost for %s, reconnecting", alias)
if reconnErr := client.Reconnect(ctx, m.getJumpClient(client.creds.Via)); reconnErr != nil {
m.mu.Lock()
m.reconnectFails[alias] = time.Now()
m.mu.Unlock()
return nil, fmt.Errorf("reconnect failed: %w", reconnErr)
}
// Clear backoff on success
m.mu.Lock()
delete(m.reconnectFails, alias)
m.mu.Unlock()
return client.Run(ctx, cmd)
}
return nil, err
}
return result, nil
}
// getJumpClient returns the jump client.
func (m *Manager) getJumpClient(via string) *Client {
if via == "" {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
return m.connections[via]
}
// isConnectionError checks if error indicates lost connection.
func isConnectionError(err error) bool {
if err == nil {
return false
}
// Type-safe checks first
if errors.Is(err, io.EOF) {
return true
}
var netErr *net.OpError
if errors.As(err, &netErr) {
return true
}
// String matching for SSH-specific errors
errStr := err.Error()
return strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "broken pipe") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "use of closed network connection")
}
// Execute runs a command and returns formatted output.
func (m *Manager) Execute(ctx context.Context, cmd, target string) (string, error) {
result, err := m.Run(ctx, cmd, target)
if err != nil {
return "", err
}
var output strings.Builder
if result.Stdout != "" {
output.WriteString(result.Stdout)
}
if result.Stderr != "" {
if output.Len() > 0 {
output.WriteString("\n")
}
output.WriteString(result.Stderr)
}
if output.Len() == 0 {
return "(No output)", nil
}
if result.ExitCode != 0 {
fmt.Fprintf(&output, "\n[Exit Code: %d]", result.ExitCode)
}
// Truncate if too long
const maxBytes = 51200
outputStr := output.String()
if len(outputStr) > maxBytes {
outputStr = outputStr[:maxBytes] + "\n... [Output truncated]"
}
return outputStr, nil
}
// resolvePath resolves a path to an absolute path using the connection's CWD.
// No path restrictions — the connected user's OS permissions are the only boundary.
func (m *Manager) resolvePath(path, alias string) string {
m.mu.RLock()
client := m.connections[alias]
m.mu.RUnlock()
cwd := "/"
if client != nil {
cwd = client.CWD()
}
if !filepath.IsAbs(path) {
path = filepath.Join(cwd, path)
}
return filepath.Clean(path)
}
// ReadFile reads a file.
func (m *Manager) ReadFile(ctx context.Context, path, target string) (string, error) {
alias, err := m.resolveTarget(target)
if err != nil {
return "", err
}
resolved := m.resolvePath(path, alias)
lock := m.getAliasLock(alias)
lock.Lock()
defer lock.Unlock()
m.mu.RLock()
client := m.connections[alias]
m.mu.RUnlock()
if client == nil {
return "", fmt.Errorf("connection '%s' is no longer active", alias)
}
sftpClient, err := client.SFTP()
if err != nil {
return "", err
}
file, err := sftpClient.Open(resolved)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Check file size before reading to prevent OOM
const maxReadSize = 10 * 1024 * 1024 // 10 MB
stat, err := file.Stat()
if err != nil {
return "", fmt.Errorf("failed to stat file: %w", err)
}
if stat.Size() > maxReadSize {
return "", fmt.Errorf("file too large (%d bytes, max %d bytes); use 'run' with head/tail to read portions", stat.Size(), maxReadSize)
}
content, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}
return string(content), nil
}
// WriteFile writes content to a file.
func (m *Manager) WriteFile(ctx context.Context, path, content, target string) error {
alias, err := m.resolveTarget(target)
if err != nil {
return err
}
resolved := m.resolvePath(path, alias)
lock := m.getAliasLock(alias)
lock.Lock()
defer lock.Unlock()
m.mu.RLock()
client := m.connections[alias]
m.mu.RUnlock()
if client == nil {
return fmt.Errorf("connection '%s' is no longer active", alias)
}
sftpClient, err := client.SFTP()
if err != nil {
return err
}
file, err := sftpClient.Create(resolved)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
_, err = file.Write([]byte(content))
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
// ListDir lists directory contents.
func (m *Manager) ListDir(ctx context.Context, path, target string) ([]FileInfo, error) {
alias, err := m.resolveTarget(target)
if err != nil {
return nil, err
}
resolved := m.resolvePath(path, alias)
lock := m.getAliasLock(alias)
lock.Lock()
defer lock.Unlock()
m.mu.RLock()
client := m.connections[alias]
m.mu.RUnlock()
if client == nil {
return nil, fmt.Errorf("connection '%s' is no longer active", alias)
}
sftpClient, err := client.SFTP()
if err != nil {
return nil, err
}
entries, err := sftpClient.ReadDir(resolved)
if err != nil {
return nil, fmt.Errorf("failed to list directory: %w", err)
}
var files []FileInfo
for _, entry := range entries {
ftype := "file"
if entry.IsDir() {
ftype = "dir"
}
files = append(files, FileInfo{
Name: entry.Name(),
Type: ftype,
Size: entry.Size(),
Permissions: entry.Mode().String(),
})
}
return files, nil
}
// FileInfo represents file metadata.
type FileInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Size int64 `json:"size"`
Permissions string `json:"permissions"`
}
// GetPublicKey returns the system's public SSH key.
func (m *Manager) GetPublicKey() (string, error) {
return m.keyManager.GetPublicKey()
}
// IsDockerAvailable checks if Docker is available on the target, with per-alias caching.
func (m *Manager) IsDockerAvailable(ctx context.Context, target string) (bool, error) {
alias, err := m.resolveTarget(target)
if err != nil {
return false, err
}
m.mu.RLock()
cached := m.dockerCache[alias]
m.mu.RUnlock()
if cached != nil {
return *cached, nil
}
output, err := m.Execute(ctx, "command -v docker >/dev/null 2>&1 && echo 'ok' || echo 'missing'", target)
if err != nil {
return false, err
}
available := strings.Contains(output, "ok")
m.mu.Lock()
m.dockerCache[alias] = &available
m.mu.Unlock()
return available, nil
}
// Close closes all connections.
func (m *Manager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
for _, client := range m.connections {
if client != nil {
client.Close()
}
}
m.connections = make(map[string]*Client)
m.aliasLocks = make(map[string]*sync.Mutex)
m.reconnectFails = make(map[string]time.Time)
m.dockerCache = make(map[string]*bool)
m.primary = ""
}