mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:01:31 +00:00
8b75e0311b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
862 lines
31 KiB
Go
862 lines
31 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"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"regexp"
|
|
"time"
|
|
|
|
"github.com/pkg/sftp"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/certctl-io/certctl/internal/connector/target"
|
|
"github.com/certctl-io/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 returns os.FileInfo for a remote file. The Mode field is
|
|
// load-bearing for the Bundle 6 pre-deploy snapshot — restoring an
|
|
// original file requires the original mode for fidelity. Callers
|
|
// detect "file does not exist" via errors.Is(err, os.ErrNotExist).
|
|
StatFile(remotePath string) (os.FileInfo, error)
|
|
// ReadFile reads the entire contents of a remote file. Used by the
|
|
// Bundle 6 pre-deploy snapshot to capture original bytes for
|
|
// reload-failure rollback. Callers should StatFile first to bound
|
|
// the read size.
|
|
ReadFile(remotePath string) ([]byte, error)
|
|
// Remove deletes a remote file. Used by the Bundle 6 rollback path
|
|
// to clean up first-time-deploy partial state — when reload fails
|
|
// and the path didn't exist pre-deploy, the new bytes must come
|
|
// off the remote so the daemon doesn't pick them up on a later
|
|
// manual restart.
|
|
Remove(remotePath string) 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. Pre-deploy snapshot (Bundle 6, 2026-05-02 audit): for each path the
|
|
// deploy will write to (cert, key, optional chain), capture original
|
|
// bytes + mode into in-memory backup buffers. StatFile errors with
|
|
// os.ErrNotExist mean the path doesn't exist (rollback = remove);
|
|
// other stat errors bail out before any write happens.
|
|
// 3. Write certificate (+ chain appended if chain_path not set) to cert_path.
|
|
// 4. Write private key to key_path with restricted permissions.
|
|
// 5. If chain_path is set and chain provided, write chain separately.
|
|
// 6. If reload_command is set, execute it via SSH.
|
|
// 7. On reload failure, restore each backed-up file (or Remove if no
|
|
// pre-existing) and re-run reload as a best-effort retry. The remote
|
|
// ends up in pre-deploy state if the rollback succeeds.
|
|
// 8. 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()
|
|
|
|
// Validate we have a private key (required for the deploy to proceed)
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Bundle 6: determine the paths the deploy will write to. Chain is
|
|
// written separately only when ChainPath is configured AND ChainPEM
|
|
// is non-empty (otherwise the chain is appended to the cert above).
|
|
chainSeparate := c.config.ChainPath != "" && request.ChainPEM != ""
|
|
|
|
type pathSpec struct {
|
|
key string // metadata key suffix: "cert" / "key" / "chain"
|
|
path string
|
|
}
|
|
writePaths := []pathSpec{
|
|
{"cert", c.config.CertPath},
|
|
{"key", c.config.KeyPath},
|
|
}
|
|
if chainSeparate {
|
|
writePaths = append(writePaths, pathSpec{"chain", c.config.ChainPath})
|
|
}
|
|
|
|
// Bundle 6: pre-deploy snapshot. For each path the deploy will touch,
|
|
// StatFile to detect existence; if present, ReadFile into an in-memory
|
|
// backup buffer keyed by remote path. Original mode captured for
|
|
// fidelity on restore. Empty backup map entry = first-time deploy
|
|
// (rollback for that path = Remove).
|
|
backups := make(map[string][]byte)
|
|
modes := make(map[string]os.FileMode)
|
|
backupStatus := map[string]string{
|
|
"cert": "no_pre_existing",
|
|
"key": "no_pre_existing",
|
|
"chain": "n/a",
|
|
}
|
|
if chainSeparate {
|
|
backupStatus["chain"] = "no_pre_existing"
|
|
}
|
|
|
|
for _, p := range writePaths {
|
|
info, err := c.client.StatFile(p.path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
// First-time deploy for this path. Rollback = Remove.
|
|
continue
|
|
}
|
|
// Real stat error — bail out before writing anything.
|
|
errMsg := fmt.Sprintf("pre-deploy stat failed for %s: %v", p.path, err)
|
|
c.logger.Error("pre-deploy stat failed", "error", err, "path", p.path)
|
|
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)
|
|
}
|
|
data, err := c.client.ReadFile(p.path)
|
|
if err != nil {
|
|
// File exists per stat but read failed — outage signal. Bail.
|
|
errMsg := fmt.Sprintf("pre-deploy backup read failed for %s: %v", p.path, err)
|
|
c.logger.Error("pre-deploy backup read failed", "error", err, "path", p.path)
|
|
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)
|
|
}
|
|
backups[p.path] = data
|
|
if info != nil {
|
|
modes[p.path] = info.Mode().Perm()
|
|
}
|
|
backupStatus[p.key] = "snapshotted"
|
|
c.logger.Debug("pre-deploy snapshot captured",
|
|
"path", p.path,
|
|
"size_bytes", len(data),
|
|
"mode", modes[p.path])
|
|
}
|
|
|
|
// 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
|
|
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 chainSeparate {
|
|
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 {
|
|
// Bundle 6: reload failed. Walk the writePaths list and either
|
|
// restore from the in-memory backup (file existed pre-deploy)
|
|
// or Remove (first-time deploy partial state). Re-run reload
|
|
// as a best-effort retry once restore completes — if THAT
|
|
// succeeds the target is fully back to pre-deploy state.
|
|
c.logger.Error("reload command failed; attempting rollback",
|
|
"error", err,
|
|
"output", output,
|
|
"reload_command", c.config.ReloadCommand)
|
|
var paths []string
|
|
for _, p := range writePaths {
|
|
paths = append(paths, p.path)
|
|
}
|
|
restoreStatuses, rollbackErr := c.restoreFromBackups(ctx, paths, backups, modes)
|
|
// Merge per-key restore status into backupStatus so operators
|
|
// see whether the rollback ran cleanly per file. restoreFromBackups
|
|
// returns statuses keyed by metadata key (cert/key/chain), not
|
|
// by remote path.
|
|
for _, p := range writePaths {
|
|
if s, ok := restoreStatuses[p.key]; ok {
|
|
backupStatus[p.key] = s
|
|
}
|
|
}
|
|
|
|
if rollbackErr != nil {
|
|
// Both reload AND rollback failed — operator-actionable.
|
|
combined := fmt.Errorf("reload failed (%w); rollback also failed (%v); manual operator inspection required", err, rollbackErr)
|
|
c.logger.Error("SSH rollback also failed",
|
|
"reload_error", err,
|
|
"rollback_error", rollbackErr)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: combined.Error(),
|
|
DeployedAt: time.Now(),
|
|
Metadata: buildMetadataWithBackup(c.config, startTime, backupStatus, map[string]string{
|
|
"reload_error": output,
|
|
"rollback_error": rollbackErr.Error(),
|
|
"rolled_back": "false",
|
|
"manual_action_required": "true",
|
|
}),
|
|
}, combined
|
|
}
|
|
|
|
// Rollback succeeded. Best-effort retry-reload — if it works,
|
|
// the daemon is serving the original cert again. If it fails,
|
|
// remote files are pre-deploy but daemon may be in a stuck
|
|
// state; surface as wrapped error so the operator knows to
|
|
// investigate the daemon, not the files.
|
|
retryOutput, retryErr := c.client.Execute(ctx, c.config.ReloadCommand)
|
|
if retryErr != nil {
|
|
wrapped := fmt.Errorf("reload failed (%w); rolled back files; retry-reload also failed (%v) — daemon may need manual restart", err, retryErr)
|
|
c.logger.Error("SSH retry-reload after rollback failed",
|
|
"reload_error", err,
|
|
"retry_reload_error", retryErr,
|
|
"retry_output", retryOutput)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: wrapped.Error(),
|
|
DeployedAt: time.Now(),
|
|
Metadata: buildMetadataWithBackup(c.config, startTime, backupStatus, map[string]string{
|
|
"reload_error": output,
|
|
"retry_reload_error": retryOutput,
|
|
"rolled_back": "true",
|
|
"daemon_state_unknown": "true",
|
|
}),
|
|
}, wrapped
|
|
}
|
|
|
|
// Clean recoverable failure: files restored, daemon reloaded
|
|
// to pre-deploy state.
|
|
errMsg := fmt.Sprintf("reload command failed; rolled back to pre-deploy state: %v (output: %s)", err, output)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
Metadata: buildMetadataWithBackup(c.config, startTime, backupStatus, map[string]string{
|
|
"reload_error": output,
|
|
"rolled_back": "true",
|
|
}),
|
|
}, 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: buildMetadataWithBackup(c.config, startTime, backupStatus, nil),
|
|
}, nil
|
|
}
|
|
|
|
// restoreFromBackups walks the configured deploy paths and either restores
|
|
// each path from the in-memory backup (when the file existed pre-deploy) or
|
|
// Removes the new bytes (first-time-deploy partial state). Returns the
|
|
// per-path status map (always populated, used by callers to emit accurate
|
|
// Metadata) and the first error encountered — caller surfaces the wrapped
|
|
// error to the operator. Per staticcheck ST1008, error is the last return.
|
|
//
|
|
// Bundle 6 of the 2026-05-02 deployment-target audit.
|
|
func (c *Connector) restoreFromBackups(ctx context.Context, paths []string, backups map[string][]byte, modes map[string]os.FileMode) (map[string]string, error) {
|
|
statuses := make(map[string]string, len(paths))
|
|
pathToKey := map[string]string{
|
|
c.config.CertPath: "cert",
|
|
c.config.KeyPath: "key",
|
|
c.config.ChainPath: "chain",
|
|
}
|
|
|
|
var firstErr error
|
|
for _, path := range paths {
|
|
key := pathToKey[path]
|
|
if data, ok := backups[path]; ok {
|
|
// File existed pre-deploy — restore from backup with the
|
|
// original mode (default 0600 if mode capture failed).
|
|
mode := modes[path]
|
|
if mode == 0 {
|
|
mode = 0600
|
|
}
|
|
if err := c.client.WriteFile(path, data, mode); err != nil {
|
|
wrapped := fmt.Errorf("restore %s: %w", path, err)
|
|
if firstErr == nil {
|
|
firstErr = wrapped
|
|
}
|
|
if key != "" {
|
|
statuses[key] = "restore_failed"
|
|
}
|
|
c.logger.Error("rollback restore failed", "error", err, "path", path)
|
|
continue
|
|
}
|
|
if key != "" {
|
|
statuses[key] = "restored"
|
|
}
|
|
c.logger.Info("rollback restored file from backup", "path", path, "size_bytes", len(data))
|
|
} else {
|
|
// First-time deploy for this path — Remove the new bytes.
|
|
if err := c.client.Remove(path); err != nil {
|
|
wrapped := fmt.Errorf("remove %s: %w", path, err)
|
|
if firstErr == nil {
|
|
firstErr = wrapped
|
|
}
|
|
if key != "" {
|
|
statuses[key] = "remove_failed"
|
|
}
|
|
c.logger.Error("rollback remove failed", "error", err, "path", path)
|
|
continue
|
|
}
|
|
if key != "" {
|
|
statuses[key] = "removed"
|
|
}
|
|
c.logger.Info("rollback removed first-time-deploy file", "path", path)
|
|
}
|
|
}
|
|
return statuses, firstErr
|
|
}
|
|
|
|
// buildMetadataWithBackup assembles the per-deploy Metadata map with the
|
|
// standard host / cert_path / key_path / duration_ms fields plus the
|
|
// per-path backup_status_{cert,key,chain} fields populated from the
|
|
// snapshot phase. Extra k/v pairs (e.g. error context) are merged on top.
|
|
//
|
|
// Bundle 6 of the 2026-05-02 deployment-target audit.
|
|
func buildMetadataWithBackup(cfg *Config, startTime time.Time, backupStatus map[string]string, extra map[string]string) map[string]string {
|
|
md := map[string]string{
|
|
"host": cfg.Host,
|
|
"cert_path": cfg.CertPath,
|
|
"key_path": cfg.KeyPath,
|
|
"duration_ms": fmt.Sprintf("%d", time.Since(startTime).Milliseconds()),
|
|
"backup_status_cert": backupStatus["cert"],
|
|
"backup_status_key": backupStatus["key"],
|
|
"backup_status_chain": backupStatus["chain"],
|
|
}
|
|
for k, v := range extra {
|
|
md[k] = v
|
|
}
|
|
return md
|
|
}
|
|
|
|
// 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 — see "Operator playbook:
|
|
// SSH host-key verification" in docs/connectors.md (SSH section) for
|
|
// the threat model accepted, the threat model rejected, available
|
|
// mitigations (custom HostKeyCallback via NewWithClient + known_hosts;
|
|
// SSH certificate authentication; network segmentation), and when not
|
|
// to use this connector. Same security rationale as the network
|
|
// scanner's InsecureSkipVerify and the F5 connector's insecure flag:
|
|
// certctl deploys to operator-configured target infrastructure on
|
|
// operator-controlled networks. Built-in known_hosts management is
|
|
// V3-Pro work (see WORKSPACE-ROADMAP.md). Top-10 fix #7 of the
|
|
// 2026-05-02 deployment-target audit re-run.
|
|
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 returns os.FileInfo for a remote file via SFTP. Bundle 6 evolved
|
|
// the signature from int64 (size only) to os.FileInfo so the pre-deploy
|
|
// snapshot can capture the original mode for accurate rollback restoration.
|
|
// Errors from SFTP wrapping a non-existent-file syscall preserve the
|
|
// os.ErrNotExist sentinel through the %w wrap, so callers can use
|
|
// errors.Is(err, os.ErrNotExist) to distinguish "file doesn't exist" from
|
|
// real stat errors.
|
|
func (c *realSSHClient) StatFile(remotePath string) (os.FileInfo, error) {
|
|
if c.sftpClient == nil {
|
|
return nil, fmt.Errorf("SFTP client not connected")
|
|
}
|
|
|
|
info, err := c.sftpClient.Stat(remotePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to stat remote file %s: %w", remotePath, err)
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// ReadFile reads the entire contents of a remote file via SFTP. Used by
|
|
// Bundle 6's pre-deploy snapshot to capture original bytes for the
|
|
// reload-failure rollback path. Callers cap the read size by inspecting
|
|
// StatFile first.
|
|
func (c *realSSHClient) ReadFile(remotePath string) ([]byte, error) {
|
|
if c.sftpClient == nil {
|
|
return nil, fmt.Errorf("SFTP client not connected")
|
|
}
|
|
|
|
f, err := c.sftpClient.Open(remotePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sftp open %s: %w", remotePath, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sftp read %s: %w", remotePath, err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// Remove deletes a remote file via SFTP. Used by Bundle 6's rollback path
|
|
// to clean up first-time-deploy partial state — when reload fails and the
|
|
// path didn't exist pre-deploy, the new bytes must come off the remote.
|
|
func (c *realSSHClient) Remove(remotePath string) error {
|
|
if c.sftpClient == nil {
|
|
return fmt.Errorf("SFTP client not connected")
|
|
}
|
|
if err := c.sftpClient.Remove(remotePath); err != nil {
|
|
return fmt.Errorf("sftp remove %s: %w", remotePath, err)
|
|
}
|
|
return 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
|
|
}
|