mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
697c0be9f3
Adds a new target connector enabling certificate deployment to any Linux/Unix server without installing the certctl agent binary. Uses the proxy agent pattern — a single agent in the same network zone deploys certs to remote servers over SSH/SFTP. Key additions: - SSH/SFTP connector with key auth (file/inline) + password auth - Injectable SSHClient interface for cross-platform testing (25 tests) - Shell injection prevention via validation.ValidateShellCommand() - Configurable cert/key/chain paths with octal permissions - GUI: 11 SSH config fields in target create wizard Also fixes pre-existing frontend bug where all target type strings (nginx, apache, etc.) were sent as lowercase but the backend expects proper-case (NGINX, Apache, etc.), breaking GUI-created targets. Adds missing TargetTypeSSH to validTargetTypes service map. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
561 lines
19 KiB
Go
561 lines
19 KiB
Go
// Package ssh implements a target.Connector for agentless certificate deployment
|
|
// via SSH/SFTP. This enables the "proxy agent" pattern — a certctl agent in the
|
|
// same network zone deploys certificates to remote servers without requiring the
|
|
// certctl agent binary on every target host.
|
|
package ssh
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"regexp"
|
|
"time"
|
|
|
|
"github.com/pkg/sftp"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/target"
|
|
"github.com/shankar0123/certctl/internal/validation"
|
|
)
|
|
|
|
// Config represents the SSH deployment target configuration.
|
|
// Supports key-based and password-based authentication for agentless
|
|
// certificate deployment to any Linux/Unix server.
|
|
type Config struct {
|
|
Host string `json:"host"` // Required. SSH hostname or IP.
|
|
Port int `json:"port"` // Default: 22.
|
|
User string `json:"user"` // Required. SSH username.
|
|
AuthMethod string `json:"auth_method"` // "key" (default) or "password".
|
|
PrivateKeyPath string `json:"private_key_path"` // Path to SSH private key file (when auth_method="key").
|
|
PrivateKey string `json:"private_key"` // Inline SSH private key PEM (alternative to path).
|
|
Password string `json:"password"` // SSH password (when auth_method="password").
|
|
Passphrase string `json:"passphrase"` // Optional passphrase for encrypted private keys.
|
|
CertPath string `json:"cert_path"` // Required. Remote path for certificate file.
|
|
KeyPath string `json:"key_path"` // Required. Remote path for private key file.
|
|
ChainPath string `json:"chain_path"` // Optional. Remote path for chain file.
|
|
CertMode string `json:"cert_mode"` // File permissions for cert (default: "0644").
|
|
KeyMode string `json:"key_mode"` // File permissions for key (default: "0600").
|
|
ReloadCommand string `json:"reload_command"` // Optional. Command to run after deployment.
|
|
Timeout int `json:"timeout"` // SSH connection timeout in seconds (default: 30).
|
|
}
|
|
|
|
// SSHClient abstracts SSH/SFTP operations for testability.
|
|
// The real implementation uses golang.org/x/crypto/ssh + github.com/pkg/sftp.
|
|
// Tests inject a mock to verify behavior without a real SSH server.
|
|
type SSHClient interface {
|
|
// Connect establishes an SSH connection to the remote host.
|
|
Connect(ctx context.Context) error
|
|
// WriteFile writes data to a remote path with the given permissions.
|
|
WriteFile(remotePath string, data []byte, mode os.FileMode) error
|
|
// Execute runs a command on the remote server and returns combined output.
|
|
Execute(ctx context.Context, command string) (string, error)
|
|
// StatFile checks if a remote file exists and returns its size.
|
|
StatFile(remotePath string) (int64, error)
|
|
// Close closes the SSH connection.
|
|
Close() error
|
|
}
|
|
|
|
// Connector implements the target.Connector interface for SSH/SFTP deployment.
|
|
// This connector runs on the AGENT side and handles remote certificate deployment
|
|
// to Linux/Unix servers without requiring the certctl agent binary on each target.
|
|
type Connector struct {
|
|
config *Config
|
|
client SSHClient
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// hostRegex validates SSH hostnames (no shell metacharacters).
|
|
var hostRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
|
|
|
// permRegex validates octal permission strings like "0644" or "0600".
|
|
var permRegex = regexp.MustCompile(`^0[0-7]{3}$`)
|
|
|
|
// New creates a new SSH target connector with the given configuration and logger.
|
|
// Returns an error if the configuration is invalid.
|
|
func New(cfg *Config, logger *slog.Logger) (*Connector, error) {
|
|
applyDefaults(cfg)
|
|
client := &realSSHClient{config: cfg}
|
|
return &Connector{
|
|
config: cfg,
|
|
client: client,
|
|
logger: logger,
|
|
}, nil
|
|
}
|
|
|
|
// NewWithClient creates a new SSH target connector with an injectable SSH client.
|
|
// Used in tests to mock SSH/SFTP operations.
|
|
func NewWithClient(cfg *Config, client SSHClient, logger *slog.Logger) *Connector {
|
|
applyDefaults(cfg)
|
|
return &Connector{
|
|
config: cfg,
|
|
client: client,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// applyDefaults fills in default values for unset config fields.
|
|
func applyDefaults(cfg *Config) {
|
|
if cfg.Port == 0 {
|
|
cfg.Port = 22
|
|
}
|
|
if cfg.AuthMethod == "" {
|
|
cfg.AuthMethod = "key"
|
|
}
|
|
if cfg.CertMode == "" {
|
|
cfg.CertMode = "0644"
|
|
}
|
|
if cfg.KeyMode == "" {
|
|
cfg.KeyMode = "0600"
|
|
}
|
|
if cfg.Timeout == 0 {
|
|
cfg.Timeout = 30
|
|
}
|
|
}
|
|
|
|
// ValidateConfig validates the SSH deployment target configuration.
|
|
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
|
var cfg Config
|
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
|
return fmt.Errorf("invalid SSH config: %w", err)
|
|
}
|
|
|
|
applyDefaults(&cfg)
|
|
|
|
// Required fields
|
|
if cfg.Host == "" {
|
|
return fmt.Errorf("SSH host is required")
|
|
}
|
|
if cfg.User == "" {
|
|
return fmt.Errorf("SSH user is required")
|
|
}
|
|
if cfg.CertPath == "" {
|
|
return fmt.Errorf("SSH cert_path is required")
|
|
}
|
|
if cfg.KeyPath == "" {
|
|
return fmt.Errorf("SSH key_path is required")
|
|
}
|
|
|
|
// Validate host (no shell metacharacters)
|
|
if !hostRegex.MatchString(cfg.Host) {
|
|
return fmt.Errorf("SSH host contains invalid characters")
|
|
}
|
|
|
|
// Auth method validation
|
|
if cfg.AuthMethod != "key" && cfg.AuthMethod != "password" {
|
|
return fmt.Errorf("SSH auth_method must be \"key\" or \"password\", got %q", cfg.AuthMethod)
|
|
}
|
|
if cfg.AuthMethod == "key" {
|
|
if cfg.PrivateKeyPath == "" && cfg.PrivateKey == "" {
|
|
return fmt.Errorf("SSH key auth requires private_key_path or private_key")
|
|
}
|
|
// If path specified, verify file exists locally
|
|
if cfg.PrivateKeyPath != "" {
|
|
if _, err := os.Stat(cfg.PrivateKeyPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("SSH private key file not found: %s", cfg.PrivateKeyPath)
|
|
}
|
|
}
|
|
}
|
|
if cfg.AuthMethod == "password" && cfg.Password == "" {
|
|
return fmt.Errorf("SSH password auth requires password")
|
|
}
|
|
|
|
// Validate file permissions
|
|
if !permRegex.MatchString(cfg.CertMode) {
|
|
return fmt.Errorf("SSH cert_mode must be octal (e.g., \"0644\"), got %q", cfg.CertMode)
|
|
}
|
|
if !permRegex.MatchString(cfg.KeyMode) {
|
|
return fmt.Errorf("SSH key_mode must be octal (e.g., \"0600\"), got %q", cfg.KeyMode)
|
|
}
|
|
|
|
// Validate reload command (if set) against shell injection
|
|
if cfg.ReloadCommand != "" {
|
|
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
|
return fmt.Errorf("SSH invalid reload_command: %w", err)
|
|
}
|
|
}
|
|
|
|
c.config = &cfg
|
|
c.logger.Info("SSH configuration validated",
|
|
"host", cfg.Host,
|
|
"port", cfg.Port,
|
|
"user", cfg.User,
|
|
"auth_method", cfg.AuthMethod,
|
|
"cert_path", cfg.CertPath,
|
|
"key_path", cfg.KeyPath)
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeployCertificate deploys a certificate to the remote server via SSH/SFTP.
|
|
//
|
|
// Steps:
|
|
// 1. Connect to remote host via SSH
|
|
// 2. Write certificate (+ chain if chain_path not set) to cert_path
|
|
// 3. Write private key to key_path with restricted permissions
|
|
// 4. If chain_path is set and chain provided, write chain separately
|
|
// 5. If reload_command is set, execute it via SSH
|
|
// 6. Close connection
|
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
|
c.logger.Info("deploying certificate via SSH",
|
|
"host", c.config.Host,
|
|
"port", c.config.Port,
|
|
"cert_path", c.config.CertPath,
|
|
"key_path", c.config.KeyPath)
|
|
|
|
startTime := time.Now()
|
|
|
|
// Connect
|
|
if err := c.client.Connect(ctx); err != nil {
|
|
errMsg := fmt.Sprintf("SSH connection failed: %v", err)
|
|
c.logger.Error("SSH connection failed", "error", err, "host", c.config.Host)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
defer c.client.Close()
|
|
|
|
// Parse file permissions
|
|
certMode, _ := parsePermissions(c.config.CertMode)
|
|
keyMode, _ := parsePermissions(c.config.KeyMode)
|
|
|
|
// Build cert data: if chain_path not set, append chain to cert (fullchain)
|
|
certData := request.CertPEM
|
|
if request.ChainPEM != "" && c.config.ChainPath == "" {
|
|
certData += "\n" + request.ChainPEM
|
|
}
|
|
|
|
// Write certificate
|
|
if err := c.client.WriteFile(c.config.CertPath, []byte(certData), certMode); err != nil {
|
|
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
|
|
c.logger.Error("certificate write failed", "error", err, "path", c.config.CertPath)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Write private key (must have KeyPEM)
|
|
if request.KeyPEM == "" {
|
|
errMsg := "SSH deployment requires private key (KeyPEM)"
|
|
c.logger.Error("missing private key")
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
if err := c.client.WriteFile(c.config.KeyPath, []byte(request.KeyPEM), keyMode); err != nil {
|
|
errMsg := fmt.Sprintf("failed to write private key: %v", err)
|
|
c.logger.Error("key write failed", "error", err, "path", c.config.KeyPath)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Write chain separately if chain_path configured
|
|
if c.config.ChainPath != "" && request.ChainPEM != "" {
|
|
if err := c.client.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), certMode); err != nil {
|
|
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
|
c.logger.Error("chain write failed", "error", err, "path", c.config.ChainPath)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
}
|
|
|
|
// Execute reload command if configured
|
|
if c.config.ReloadCommand != "" {
|
|
c.logger.Debug("executing reload command", "command", c.config.ReloadCommand)
|
|
output, err := c.client.Execute(ctx, c.config.ReloadCommand)
|
|
if err != nil {
|
|
errMsg := fmt.Sprintf("reload command failed: %v (output: %s)", err, output)
|
|
c.logger.Error("reload command failed", "error", err, "output", output)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
}
|
|
|
|
deploymentDuration := time.Since(startTime)
|
|
c.logger.Info("certificate deployed via SSH successfully",
|
|
"host", c.config.Host,
|
|
"duration", deploymentDuration.String(),
|
|
"cert_path", c.config.CertPath)
|
|
|
|
return &target.DeploymentResult{
|
|
Success: true,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
DeploymentID: fmt.Sprintf("ssh-%s-%d", c.config.Host, time.Now().Unix()),
|
|
Message: fmt.Sprintf("Certificate deployed via SSH to %s", c.config.Host),
|
|
DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"host": c.config.Host,
|
|
"cert_path": c.config.CertPath,
|
|
"key_path": c.config.KeyPath,
|
|
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ValidateDeployment verifies that the deployed certificate files exist on the remote server.
|
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
|
c.logger.Info("validating SSH deployment",
|
|
"host", c.config.Host,
|
|
"certificate_id", request.CertificateID,
|
|
"serial", request.Serial)
|
|
|
|
startTime := time.Now()
|
|
|
|
// Connect
|
|
if err := c.client.Connect(ctx); err != nil {
|
|
errMsg := fmt.Sprintf("SSH connection failed during validation: %v", err)
|
|
c.logger.Error("SSH connection failed", "error", err)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
defer c.client.Close()
|
|
|
|
// Verify cert file exists
|
|
if _, err := c.client.StatFile(c.config.CertPath); err != nil {
|
|
errMsg := fmt.Sprintf("certificate file not found on remote: %s (%v)", c.config.CertPath, err)
|
|
c.logger.Error("validation failed", "error", err)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Verify key file exists
|
|
if _, err := c.client.StatFile(c.config.KeyPath); err != nil {
|
|
errMsg := fmt.Sprintf("key file not found on remote: %s (%v)", c.config.KeyPath, err)
|
|
c.logger.Error("validation failed", "error", err)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
validationDuration := time.Since(startTime)
|
|
c.logger.Info("SSH deployment validated successfully",
|
|
"host", c.config.Host,
|
|
"duration", validationDuration.String())
|
|
|
|
return &target.ValidationResult{
|
|
Valid: true,
|
|
Serial: request.Serial,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: "Certificate and key files accessible on remote server",
|
|
ValidatedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"host": c.config.Host,
|
|
"cert_path": c.config.CertPath,
|
|
"key_path": c.config.KeyPath,
|
|
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// parsePermissions converts an octal permission string like "0644" to os.FileMode.
|
|
func parsePermissions(s string) (os.FileMode, error) {
|
|
var mode uint32
|
|
_, err := fmt.Sscanf(s, "%o", &mode)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid permission string %q: %w", s, err)
|
|
}
|
|
return os.FileMode(mode), nil
|
|
}
|
|
|
|
// --- Real SSH client implementation ---
|
|
|
|
// realSSHClient implements SSHClient using golang.org/x/crypto/ssh + github.com/pkg/sftp.
|
|
type realSSHClient struct {
|
|
config *Config
|
|
sshClient *ssh.Client
|
|
sftpClient *sftp.Client
|
|
}
|
|
|
|
// Connect establishes an SSH connection to the remote host.
|
|
func (c *realSSHClient) Connect(ctx context.Context) error {
|
|
authMethods, err := c.buildAuthMethods()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build SSH auth: %w", err)
|
|
}
|
|
|
|
sshConfig := &ssh.ClientConfig{
|
|
User: c.config.User,
|
|
Auth: authMethods,
|
|
Timeout: time.Duration(c.config.Timeout) * time.Second,
|
|
// InsecureIgnoreHostKey is used intentionally: certctl deploys to known
|
|
// infrastructure (the operator explicitly configures each target host).
|
|
// This is the same security rationale as network scanner's InsecureSkipVerify
|
|
// and F5 connector's insecure flag. Host key verification would require
|
|
// an additional known_hosts management layer that is out of scope.
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
}
|
|
|
|
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", c.config.Port))
|
|
|
|
// Use net.DialTimeout for context-aware connection (context cancellation
|
|
// is handled by the timeout on the SSH client config)
|
|
conn, err := net.DialTimeout("tcp", addr, sshConfig.Timeout)
|
|
if err != nil {
|
|
return fmt.Errorf("TCP connection to %s failed: %w", addr, err)
|
|
}
|
|
|
|
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, sshConfig)
|
|
if err != nil {
|
|
conn.Close()
|
|
return fmt.Errorf("SSH handshake with %s failed: %w", addr, err)
|
|
}
|
|
|
|
c.sshClient = ssh.NewClient(sshConn, chans, reqs)
|
|
|
|
// Open SFTP session
|
|
c.sftpClient, err = sftp.NewClient(c.sshClient)
|
|
if err != nil {
|
|
c.sshClient.Close()
|
|
c.sshClient = nil
|
|
return fmt.Errorf("SFTP session failed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// buildAuthMethods constructs SSH auth methods from the config.
|
|
func (c *realSSHClient) buildAuthMethods() ([]ssh.AuthMethod, error) {
|
|
switch c.config.AuthMethod {
|
|
case "password":
|
|
return []ssh.AuthMethod{ssh.Password(c.config.Password)}, nil
|
|
|
|
case "key":
|
|
var keyData []byte
|
|
var err error
|
|
|
|
if c.config.PrivateKey != "" {
|
|
keyData = []byte(c.config.PrivateKey)
|
|
} else if c.config.PrivateKeyPath != "" {
|
|
keyData, err = os.ReadFile(c.config.PrivateKeyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read private key %s: %w", c.config.PrivateKeyPath, err)
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("key auth requires private_key or private_key_path")
|
|
}
|
|
|
|
var signer ssh.Signer
|
|
if c.config.Passphrase != "" {
|
|
signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(c.config.Passphrase))
|
|
} else {
|
|
signer, err = ssh.ParsePrivateKey(keyData)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
|
}
|
|
|
|
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unsupported auth method: %s", c.config.AuthMethod)
|
|
}
|
|
}
|
|
|
|
// WriteFile writes data to a remote path via SFTP with the given permissions.
|
|
func (c *realSSHClient) WriteFile(remotePath string, data []byte, mode os.FileMode) error {
|
|
if c.sftpClient == nil {
|
|
return fmt.Errorf("SFTP client not connected")
|
|
}
|
|
|
|
f, err := c.sftpClient.Create(remotePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create remote file %s: %w", remotePath, err)
|
|
}
|
|
|
|
if _, err := f.Write(data); err != nil {
|
|
f.Close()
|
|
return fmt.Errorf("failed to write remote file %s: %w", remotePath, err)
|
|
}
|
|
|
|
if err := f.Close(); err != nil {
|
|
return fmt.Errorf("failed to close remote file %s: %w", remotePath, err)
|
|
}
|
|
|
|
// Set file permissions
|
|
if err := c.sftpClient.Chmod(remotePath, mode); err != nil {
|
|
return fmt.Errorf("failed to set permissions on %s: %w", remotePath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Execute runs a command on the remote server and returns combined output.
|
|
func (c *realSSHClient) Execute(ctx context.Context, command string) (string, error) {
|
|
if c.sshClient == nil {
|
|
return "", fmt.Errorf("SSH client not connected")
|
|
}
|
|
|
|
session, err := c.sshClient.NewSession()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create SSH session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
output, err := session.CombinedOutput(command)
|
|
return string(output), err
|
|
}
|
|
|
|
// StatFile checks if a remote file exists and returns its size.
|
|
func (c *realSSHClient) StatFile(remotePath string) (int64, error) {
|
|
if c.sftpClient == nil {
|
|
return 0, fmt.Errorf("SFTP client not connected")
|
|
}
|
|
|
|
info, err := c.sftpClient.Stat(remotePath)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to stat remote file %s: %w", remotePath, err)
|
|
}
|
|
|
|
return info.Size(), nil
|
|
}
|
|
|
|
// Close closes the SFTP and SSH connections.
|
|
func (c *realSSHClient) Close() error {
|
|
if c.sftpClient != nil {
|
|
c.sftpClient.Close()
|
|
c.sftpClient = nil
|
|
}
|
|
if c.sshClient != nil {
|
|
c.sshClient.Close()
|
|
c.sshClient = nil
|
|
}
|
|
return nil
|
|
}
|