mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 23:18:55 +00:00
feat(M39): IIS WinRM proxy agent mode + front-to-back wiring
Complete the IIS target connector with dual-mode deployment: - WinRM proxy agent mode via masterzen/winrm for remote Windows servers - Base64 PFX transfer with try/finally cleanup on remote host - GUI wizard updated with 13 IIS config fields including WinRM settings - TargetDetailPage sensitive field redaction (password/secret/token/key) - OpenAPI TargetType enum updated (added Traefik, Caddy) - connectors.md fully documented with WinRM proxy config example - 38 total IIS tests (10 new WinRM tests), all passing with race detection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
@@ -21,7 +22,9 @@ import (
|
||||
)
|
||||
|
||||
// Config represents the IIS deployment target configuration.
|
||||
// This configuration is for Windows agents that manage IIS servers.
|
||||
// Supports two modes:
|
||||
// - "local" (default): runs PowerShell locally on a Windows agent
|
||||
// - "winrm": connects to a remote Windows server via WinRM (proxy agent pattern)
|
||||
type Config struct {
|
||||
Hostname string `json:"hostname"` // Target hostname or IP
|
||||
SiteName string `json:"site_name"` // IIS site name (e.g., "Default Web Site")
|
||||
@@ -30,6 +33,10 @@ type Config struct {
|
||||
Port int `json:"port"` // HTTPS port (default 443)
|
||||
SNI bool `json:"sni"` // Enable Server Name Indication
|
||||
IPAddress string `json:"ip_address"` // Bind to specific IP (default "*")
|
||||
Mode string `json:"mode"` // "local" (default) or "winrm"
|
||||
|
||||
// WinRM settings (only used when Mode is "winrm")
|
||||
WinRM WinRMConfig `json:"winrm"`
|
||||
}
|
||||
|
||||
// PowerShellExecutor abstracts PowerShell command execution for testability.
|
||||
@@ -69,13 +76,33 @@ type Connector struct {
|
||||
}
|
||||
|
||||
// New creates a new IIS target connector with the given configuration and logger.
|
||||
// Uses the real PowerShell executor for production deployments.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
// In "local" mode (default), uses the real PowerShell executor.
|
||||
// In "winrm" mode, creates a WinRM client for remote execution.
|
||||
func New(config *Config, logger *slog.Logger) (*Connector, error) {
|
||||
mode := config.Mode
|
||||
if mode == "" {
|
||||
mode = "local"
|
||||
}
|
||||
|
||||
var executor PowerShellExecutor
|
||||
switch mode {
|
||||
case "local":
|
||||
executor = &realExecutor{}
|
||||
case "winrm":
|
||||
winrmExec, err := newWinRMExecutor(&config.WinRM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize WinRM executor: %w", err)
|
||||
}
|
||||
executor = winrmExec
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported IIS connector mode %q (must be 'local' or 'winrm')", mode)
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
executor: &realExecutor{},
|
||||
}
|
||||
executor: executor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewWithExecutor creates a new IIS target connector with an injected executor.
|
||||
@@ -157,15 +184,26 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
}
|
||||
}
|
||||
|
||||
// Apply mode default
|
||||
if cfg.Mode == "" {
|
||||
cfg.Mode = "local"
|
||||
}
|
||||
if cfg.Mode != "local" && cfg.Mode != "winrm" {
|
||||
return fmt.Errorf("unsupported mode %q (must be 'local' or 'winrm')", cfg.Mode)
|
||||
}
|
||||
|
||||
c.logger.Info("validating IIS configuration",
|
||||
"site_name", cfg.SiteName,
|
||||
"cert_store", cfg.CertStore,
|
||||
"hostname", cfg.Hostname,
|
||||
"port", cfg.Port)
|
||||
"port", cfg.Port,
|
||||
"mode", cfg.Mode)
|
||||
|
||||
// Verify PowerShell is available
|
||||
if _, err := exec.LookPath("powershell.exe"); err != nil {
|
||||
return fmt.Errorf("powershell.exe not found in PATH: %w", err)
|
||||
// Verify PowerShell is available (only in local mode — WinRM handles this remotely)
|
||||
if cfg.Mode == "local" {
|
||||
if _, err := exec.LookPath("powershell.exe"); err != nil {
|
||||
return fmt.Errorf("powershell.exe not found in PATH: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify IIS site exists
|
||||
@@ -240,33 +278,9 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Step 2: Write PFX to temp file
|
||||
tmpFile, err := os.CreateTemp("", "certctl-*.pfx")
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to create temp PFX file: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
pfxPath := tmpFile.Name()
|
||||
defer os.Remove(pfxPath) // Always clean up temp PFX
|
||||
|
||||
if _, err := tmpFile.Write(pfxData); err != nil {
|
||||
tmpFile.Close()
|
||||
errMsg := fmt.Sprintf("failed to write temp PFX file: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Step 3: Compute thumbprint (SHA-1 of DER-encoded cert — matches Windows certutil)
|
||||
// Step 2+3: Compute thumbprint and import PFX
|
||||
// In local mode: write PFX to temp file, import via file path
|
||||
// In WinRM mode: base64-encode PFX, decode on remote side to temp file, import, clean up
|
||||
thumbprint, err := computeThumbprint(request.CertPEM)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to compute certificate thumbprint: %v", err)
|
||||
@@ -281,11 +295,57 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
c.logger.Debug("certificate thumbprint computed", "thumbprint", thumbprint)
|
||||
|
||||
// Step 4: Import PFX to Windows certificate store
|
||||
importScript := fmt.Sprintf(
|
||||
`$password = ConvertTo-SecureString -String '%s' -AsPlainText -Force; `+
|
||||
`Import-PfxCertificate -FilePath '%s' -CertStoreLocation 'Cert:\LocalMachine\%s' -Password $password`,
|
||||
pfxPassword, pfxPath, c.config.CertStore,
|
||||
)
|
||||
var importScript string
|
||||
mode := c.config.Mode
|
||||
if mode == "" {
|
||||
mode = "local"
|
||||
}
|
||||
|
||||
if mode == "winrm" {
|
||||
// WinRM mode: base64-encode PFX, decode on remote, import, cleanup
|
||||
pfxBase64 := base64.StdEncoding.EncodeToString(pfxData)
|
||||
importScript = fmt.Sprintf(
|
||||
`$pfxPath = [System.IO.Path]::GetTempFileName() + '.pfx'; `+
|
||||
`[System.IO.File]::WriteAllBytes($pfxPath, [System.Convert]::FromBase64String('%s')); `+
|
||||
`try { `+
|
||||
`$password = ConvertTo-SecureString -String '%s' -AsPlainText -Force; `+
|
||||
`Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation 'Cert:\LocalMachine\%s' -Password $password `+
|
||||
`} finally { Remove-Item -Path $pfxPath -Force -ErrorAction SilentlyContinue }`,
|
||||
pfxBase64, pfxPassword, c.config.CertStore,
|
||||
)
|
||||
} else {
|
||||
// Local mode: write PFX to local temp file
|
||||
tmpFile, fileErr := os.CreateTemp("", "certctl-*.pfx")
|
||||
if fileErr != nil {
|
||||
errMsg := fmt.Sprintf("failed to create temp PFX file: %v", fileErr)
|
||||
c.logger.Error("deployment failed", "error", fileErr)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
pfxPath := tmpFile.Name()
|
||||
defer os.Remove(pfxPath) // Always clean up temp PFX
|
||||
|
||||
if _, writeErr := tmpFile.Write(pfxData); writeErr != nil {
|
||||
tmpFile.Close()
|
||||
errMsg := fmt.Sprintf("failed to write temp PFX file: %v", writeErr)
|
||||
c.logger.Error("deployment failed", "error", writeErr)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
importScript = fmt.Sprintf(
|
||||
`$password = ConvertTo-SecureString -String '%s' -AsPlainText -Force; `+
|
||||
`Import-PfxCertificate -FilePath '%s' -CertStoreLocation 'Cert:\LocalMachine\%s' -Password $password`,
|
||||
pfxPassword, pfxPath, c.config.CertStore,
|
||||
)
|
||||
}
|
||||
|
||||
output, err := c.executor.Execute(ctx, importScript)
|
||||
if err != nil {
|
||||
|
||||
@@ -843,3 +843,216 @@ func TestGenerateRandomPassword(t *testing.T) {
|
||||
t.Error("two generated passwords should be different")
|
||||
}
|
||||
}
|
||||
|
||||
// --- WinRM mode tests ---
|
||||
|
||||
func TestIISConnector_ValidateConfig_WinRMMode(t *testing.T) {
|
||||
executor := newMockExecutor()
|
||||
executor.responses["Get-Website"] = mockResponse{output: "Default Web Site\n", err: nil}
|
||||
executor.responses["Test-Path"] = mockResponse{output: "True\n", err: nil}
|
||||
|
||||
cfg := Config{
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Mode: "winrm",
|
||||
WinRM: WinRMConfig{
|
||||
Host: "iis-server.example.com",
|
||||
Port: 5985,
|
||||
Username: "Administrator",
|
||||
Password: "P@ssw0rd",
|
||||
},
|
||||
}
|
||||
|
||||
// WinRM mode should NOT check for powershell.exe locally
|
||||
connector := NewWithExecutor(&cfg, testLogger(), executor)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
err := connector.ValidateConfig(context.Background(), rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed in WinRM mode: %v", err)
|
||||
}
|
||||
|
||||
// Verify PowerShell commands were executed via the executor (not locally)
|
||||
if len(executor.commands) < 2 {
|
||||
t.Fatalf("expected at least 2 executor commands, got %d", len(executor.commands))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateConfig_InvalidMode(t *testing.T) {
|
||||
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
||||
cfg := Config{
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Mode: "invalid",
|
||||
}
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
err := connector.ValidateConfig(context.Background(), rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported mode") {
|
||||
t.Errorf("expected 'unsupported mode' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_DeployCertificate_WinRMMode(t *testing.T) {
|
||||
executor := newMockExecutor()
|
||||
executor.defaultOutput = "OK"
|
||||
|
||||
cfg := Config{
|
||||
Hostname: "iis-server.example.com",
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Port: 443,
|
||||
IPAddress: "*",
|
||||
Mode: "winrm",
|
||||
}
|
||||
|
||||
connector := NewWithExecutor(&cfg, testLogger(), executor)
|
||||
certPEM, keyPEM, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate in WinRM mode failed: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify the import script used base64 encoding (WinRM mode)
|
||||
foundBase64Import := false
|
||||
for _, cmd := range executor.commands {
|
||||
if strings.Contains(cmd, "FromBase64String") && strings.Contains(cmd, "Import-PfxCertificate") {
|
||||
foundBase64Import = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundBase64Import {
|
||||
t.Error("WinRM mode should use base64-encoded PFX transfer, but no FromBase64String found in commands")
|
||||
}
|
||||
|
||||
// Verify remote temp file cleanup is in the script
|
||||
foundCleanup := false
|
||||
for _, cmd := range executor.commands {
|
||||
if strings.Contains(cmd, "Remove-Item") && strings.Contains(cmd, "finally") {
|
||||
foundCleanup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundCleanup {
|
||||
t.Error("WinRM mode should include remote temp file cleanup (try/finally Remove-Item)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_New_WinRMMode_MissingHost(t *testing.T) {
|
||||
cfg := Config{
|
||||
Mode: "winrm",
|
||||
WinRM: WinRMConfig{
|
||||
Username: "admin",
|
||||
Password: "pass",
|
||||
},
|
||||
}
|
||||
_, err := New(&cfg, testLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing WinRM host")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "winrm_host is required") {
|
||||
t.Errorf("expected 'winrm_host is required' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_New_WinRMMode_MissingUsername(t *testing.T) {
|
||||
cfg := Config{
|
||||
Mode: "winrm",
|
||||
WinRM: WinRMConfig{
|
||||
Host: "server.example.com",
|
||||
Password: "pass",
|
||||
},
|
||||
}
|
||||
_, err := New(&cfg, testLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing WinRM username")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "winrm_username is required") {
|
||||
t.Errorf("expected 'winrm_username is required' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_New_WinRMMode_MissingPassword(t *testing.T) {
|
||||
cfg := Config{
|
||||
Mode: "winrm",
|
||||
WinRM: WinRMConfig{
|
||||
Host: "server.example.com",
|
||||
Username: "admin",
|
||||
},
|
||||
}
|
||||
_, err := New(&cfg, testLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing WinRM password")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "winrm_password is required") {
|
||||
t.Errorf("expected 'winrm_password is required' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_New_InvalidMode(t *testing.T) {
|
||||
cfg := Config{Mode: "ssh"}
|
||||
_, err := New(&cfg, testLogger())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported IIS connector mode") {
|
||||
t.Errorf("expected 'unsupported IIS connector mode' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_New_DefaultLocalMode(t *testing.T) {
|
||||
cfg := Config{} // No mode specified — should default to local
|
||||
connector, err := New(&cfg, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New() with default mode failed: %v", err)
|
||||
}
|
||||
if connector == nil {
|
||||
t.Fatal("expected non-nil connector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWinRMConfig_DefaultPorts(t *testing.T) {
|
||||
// HTTP default: 5985
|
||||
cfg := &WinRMConfig{
|
||||
Host: "server.example.com",
|
||||
Username: "admin",
|
||||
Password: "pass",
|
||||
}
|
||||
exec, err := newWinRMExecutor(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("newWinRMExecutor failed: %v", err)
|
||||
}
|
||||
if exec == nil {
|
||||
t.Fatal("expected non-nil executor")
|
||||
}
|
||||
|
||||
// HTTPS default: 5986
|
||||
cfgHTTPS := &WinRMConfig{
|
||||
Host: "server.example.com",
|
||||
Username: "admin",
|
||||
Password: "pass",
|
||||
UseHTTPS: true,
|
||||
Insecure: true,
|
||||
}
|
||||
execHTTPS, err := newWinRMExecutor(cfgHTTPS)
|
||||
if err != nil {
|
||||
t.Fatalf("newWinRMExecutor (HTTPS) failed: %v", err)
|
||||
}
|
||||
if execHTTPS == nil {
|
||||
t.Fatal("expected non-nil HTTPS executor")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package iis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/masterzen/winrm"
|
||||
)
|
||||
|
||||
// WinRMConfig holds WinRM connection settings for remote IIS management.
|
||||
// Used when Mode is "winrm" — the proxy agent connects to a remote Windows
|
||||
// server over WinRM and executes PowerShell commands remotely.
|
||||
type WinRMConfig struct {
|
||||
Host string `json:"winrm_host"` // WinRM target hostname or IP (required)
|
||||
Port int `json:"winrm_port"` // WinRM port (default 5985 for HTTP, 5986 for HTTPS)
|
||||
Username string `json:"winrm_username"` // Windows user (e.g., "Administrator")
|
||||
Password string `json:"winrm_password"` // Windows password
|
||||
UseHTTPS bool `json:"winrm_https"` // Use HTTPS (port 5986) instead of HTTP (port 5985)
|
||||
Insecure bool `json:"winrm_insecure"` // Skip TLS certificate verification (for self-signed certs)
|
||||
Timeout int `json:"winrm_timeout"` // Operation timeout in seconds (default 60)
|
||||
}
|
||||
|
||||
// winrmExecutor implements PowerShellExecutor by running PowerShell commands
|
||||
// on a remote Windows server via WinRM. This enables the proxy agent pattern:
|
||||
// a Linux agent in the same network zone manages Windows IIS servers remotely.
|
||||
type winrmExecutor struct {
|
||||
client *winrm.Client
|
||||
}
|
||||
|
||||
// newWinRMExecutor creates a WinRM client and returns a PowerShellExecutor.
|
||||
func newWinRMExecutor(cfg *WinRMConfig) (*winrmExecutor, error) {
|
||||
if cfg.Host == "" {
|
||||
return nil, fmt.Errorf("winrm_host is required for WinRM mode")
|
||||
}
|
||||
if cfg.Username == "" {
|
||||
return nil, fmt.Errorf("winrm_username is required for WinRM mode")
|
||||
}
|
||||
if cfg.Password == "" {
|
||||
return nil, fmt.Errorf("winrm_password is required for WinRM mode")
|
||||
}
|
||||
|
||||
port := cfg.Port
|
||||
if port == 0 {
|
||||
if cfg.UseHTTPS {
|
||||
port = 5986
|
||||
} else {
|
||||
port = 5985
|
||||
}
|
||||
}
|
||||
|
||||
timeout := time.Duration(cfg.Timeout) * time.Second
|
||||
if cfg.Timeout == 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
|
||||
endpoint := winrm.NewEndpoint(
|
||||
cfg.Host,
|
||||
port,
|
||||
cfg.UseHTTPS,
|
||||
cfg.Insecure,
|
||||
nil, // CA cert
|
||||
nil, // Client cert
|
||||
nil, // Client key
|
||||
timeout,
|
||||
)
|
||||
|
||||
client, err := winrm.NewClient(endpoint, cfg.Username, cfg.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create WinRM client: %w", err)
|
||||
}
|
||||
|
||||
return &winrmExecutor{client: client}, nil
|
||||
}
|
||||
|
||||
// Execute runs a PowerShell script on the remote Windows server via WinRM.
|
||||
// The script is wrapped in powershell.exe invocation on the remote side.
|
||||
func (e *winrmExecutor) Execute(ctx context.Context, script string) (string, error) {
|
||||
// RunPSWithContext returns (stdout, stderr, exitCode, error)
|
||||
stdout, stderr, exitCode, err := e.client.RunPSWithContext(ctx, script)
|
||||
if err != nil {
|
||||
return stdout + stderr, fmt.Errorf("WinRM command failed: %w", err)
|
||||
}
|
||||
if exitCode != 0 {
|
||||
return stdout + stderr, fmt.Errorf("PowerShell exited with code %d: %s", exitCode, stdout+stderr)
|
||||
}
|
||||
|
||||
return stdout, nil
|
||||
}
|
||||
Reference in New Issue
Block a user