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:
Shankar
2026-04-02 20:53:20 -04:00
parent 8f1dd10eac
commit e555af9288
10 changed files with 546 additions and 60 deletions
+101 -41
View File
@@ -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 {
+213
View File
@@ -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")
}
}
+89
View File
@@ -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
}