Initial commit: forge-tools-ssh — MCP-сервер для администрирования по SSH
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// Package ssh provides SSH connection management for the MCP server.
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
// DevKeyPath - локальный путь по умолчанию для запуска вне forge
|
||||
// (без SSH_MCP_KEY_PATH). Производственный запуск всегда передаёт
|
||||
// SSH_MCP_KEY_PATH от forge (см. cmd/serve/wiring.go): либо системный
|
||||
// ключ data/_system/ssh/id_ed25519, либо пер-агентный ssh_key_dir.
|
||||
DevKeyPath = "./data/_system/ssh/id_ed25519"
|
||||
)
|
||||
|
||||
// KeyManager handles SSH key generation and loading.
|
||||
type KeyManager struct {
|
||||
keyPath string
|
||||
}
|
||||
|
||||
// NewKeyManager creates a new KeyManager. If keyPath is empty, the system
|
||||
// default is used (SSH_MCP_KEY_PATH env, otherwise local ./data/_system/ssh).
|
||||
func NewKeyManager(keyPath string) *KeyManager {
|
||||
if keyPath == "" {
|
||||
keyPath = getDefaultKeyPath()
|
||||
}
|
||||
return &KeyManager{keyPath: keyPath}
|
||||
}
|
||||
|
||||
// getDefaultKeyPath returns the appropriate path. Honors SSH_MCP_KEY_PATH if
|
||||
// set (forge всегда так делает); иначе локальный дефолт под единой схемой
|
||||
// данных (data/_system/ssh). Автодетект окружения убран: никаких жёстко
|
||||
// зашитых /data - путь всегда приходит от оператора через env.
|
||||
func getDefaultKeyPath() string {
|
||||
if p := os.Getenv("SSH_MCP_KEY_PATH"); p != "" {
|
||||
return p
|
||||
}
|
||||
return DevKeyPath
|
||||
}
|
||||
|
||||
// EnsureKey ensures the system key exists, generating if necessary.
|
||||
func (km *KeyManager) EnsureKey() error {
|
||||
keyDir := filepath.Dir(km.keyPath)
|
||||
|
||||
// Check if directory exists
|
||||
stat, err := os.Stat(keyDir)
|
||||
if os.IsNotExist(err) {
|
||||
// Directory doesn't exist - create it (0700; единственные личные данные).
|
||||
if err := os.MkdirAll(keyDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create key directory %s: %w", keyDir, err)
|
||||
}
|
||||
log.Printf("ssh-key: created directory %s", keyDir)
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to access key directory %s: %w", keyDir, err)
|
||||
} else if !stat.IsDir() {
|
||||
return fmt.Errorf("key path %s exists but is not a directory", keyDir)
|
||||
}
|
||||
|
||||
// Test write permissions by attempting to create a temp file
|
||||
testFile := filepath.Join(keyDir, ".write_test")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
|
||||
return fmt.Errorf("key directory %s is not writable: %w", keyDir, err)
|
||||
}
|
||||
os.Remove(testFile)
|
||||
|
||||
if _, err := os.Stat(km.keyPath); os.IsNotExist(err) {
|
||||
log.Printf("ssh-key: generating new key at %s", km.keyPath)
|
||||
return km.generateKey()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateKey creates a new Ed25519 key pair.
|
||||
func (km *KeyManager) generateKey() error {
|
||||
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate key: %w", err)
|
||||
}
|
||||
|
||||
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "ssh-mcp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal private key: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(km.keyPath, pem.EncodeToMemory(privKeyBytes), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write private key: %w", err)
|
||||
}
|
||||
|
||||
sshPubKey, err := ssh.NewPublicKey(pubKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SSH public key: %w", err)
|
||||
}
|
||||
|
||||
// Add "SSH-MCP" comment to public key for identification
|
||||
pubKeyBytes := []byte(fmt.Sprintf("%s %s SSH-MCP\n",
|
||||
sshPubKey.Type(),
|
||||
base64.StdEncoding.EncodeToString(sshPubKey.Marshal())))
|
||||
|
||||
if err := os.WriteFile(km.keyPath+".pub", pubKeyBytes, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write public key: %w", err)
|
||||
}
|
||||
|
||||
log.Println("ssh-key: generated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadPrivateKey loads the private key from disk.
|
||||
func (km *KeyManager) LoadPrivateKey() (ssh.Signer, error) {
|
||||
keyBytes, err := os.ReadFile(km.keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read private key: %w", err)
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// GetPublicKey returns the public key string.
|
||||
func (km *KeyManager) GetPublicKey() (string, error) {
|
||||
pubKeyBytes, err := os.ReadFile(km.keyPath + ".pub")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read public key: %w", err)
|
||||
}
|
||||
return string(pubKeyBytes), nil
|
||||
}
|
||||
Reference in New Issue
Block a user