Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing function Split-List { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return @() } return $Text -split '[,\r\n;]+' | ForEach-Object { $_.Trim() } | Where-Object { $_ } } function Merge-Hostnames { param( [string[]]$Existing, [string[]]$NewItems ) $set = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase) foreach ($item in $Existing) { [void]$set.Add($item) } foreach ($item in $NewItems) { [void]$set.Add($item) } return $set | Sort-Object } function Get-LocalIpv4 { try { $candidates = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.IPAddress -and $_.IPAddress -notlike "169.254.*" -and $_.IPAddress -ne "127.0.0.1" -and $_.PrefixOrigin -in @("Dhcp", "Manual") } | Sort-Object -Property InterfaceMetric, SkipAsSource $ip = $candidates | Select-Object -First 1 -ExpandProperty IPAddress if ($ip) { return $ip } } catch { # Ignore and fallback to DNS lookup. } try { $dnsIps = [System.Net.Dns]::GetHostAddresses($env:COMPUTERNAME) | Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork -and $_.ToString() -notlike "169.254.*" -and $_.ToString() -ne "127.0.0.1" } | Select-Object -First 1 if ($dnsIps) { return $dnsIps.ToString() } } catch { # Ignore. } return "" } function Get-FilePreview { param( [string]$Path, [int]$MaxLines = 200 ) if (-not (Test-Path -Path $Path -PathType Leaf)) { return "" } try { $lines = Get-Content -Path $Path -TotalCount $MaxLines -ErrorAction Stop return ($lines -join [Environment]::NewLine) } catch { return "Failed to read file preview: $($_.Exception.Message)" } } function Get-CommonZoneFromHosts { param([string[]]$Hosts) $fqdnHosts = $Hosts | Where-Object { $_ -and $_.Contains(".") } if (-not $fqdnHosts -or $fqdnHosts.Count -eq 0) { return "" } $commonSuffix = $null foreach ($hostName in $fqdnHosts) { $clean = $hostName.Trim().TrimEnd(".") $labels = $clean -split "\." if ($labels.Count -lt 2) { continue } $zoneLabels = $labels[1..($labels.Count - 1)] if (-not $commonSuffix) { $commonSuffix = $zoneLabels continue } $i = $commonSuffix.Count - 1 $j = $zoneLabels.Count - 1 $newSuffix = @() while ($i -ge 0 -and $j -ge 0) { if ($commonSuffix[$i].ToLower() -ne $zoneLabels[$j].ToLower()) { break } $newSuffix = ,$zoneLabels[$j] + $newSuffix $i-- $j-- } $commonSuffix = $newSuffix if (-not $commonSuffix -or $commonSuffix.Count -eq 0) { break } } if (-not $commonSuffix -or $commonSuffix.Count -eq 0) { return "" } return ($commonSuffix -join ".") } function Get-DefaultsPath { $dir = Join-Path $env:ProgramData "Certy" return Join-Path $dir "defaults.json" } function Load-Defaults { $path = Get-DefaultsPath if (-not (Test-Path -Path $path -PathType Leaf)) { return $null } try { $raw = Get-Content -Path $path -Raw -ErrorAction Stop return $raw | ConvertFrom-Json } catch { return $null } } function Save-Defaults { param([pscustomobject]$Defaults) $path = Get-DefaultsPath $dir = Split-Path -Path $path -Parent if (-not (Test-Path -Path $dir -PathType Container)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } $json = $Defaults | ConvertTo-Json -Depth 6 Set-Content -Path $path -Value $json -Encoding ascii } function Resolve-HostEntry { param( [string]$Name, [string]$Zone, [bool]$UseProvidedFqdn ) $name = $Name.Trim() if (-not $name) { return $null } $zoneLower = $Zone.ToLower() $nameLower = $name.ToLower() if ($UseProvidedFqdn) { if ($name -like "*.*") { $fqdn = $name } else { $fqdn = "$name.$Zone" } } else { if ($name -like "*.*") { if ($nameLower.EndsWith(".$zoneLower") -or $nameLower -eq $zoneLower) { $fqdn = $name } else { $fqdn = "$name.$Zone" } } else { $fqdn = "$name.$Zone" } } $fqdnLower = $fqdn.ToLower() if ($fqdnLower.EndsWith(".$zoneLower")) { $hostLabel = $fqdn.Substring(0, $fqdn.Length - $Zone.Length - 1) } elseif ($fqdnLower -eq $zoneLower) { $hostLabel = "@" } else { $hostLabel = $fqdn } return [pscustomobject]@{ Input = $name Fqdn = $fqdn HostLabel = $hostLabel } } function Get-DnsServerCandidates { $servers = @() try { Import-Module ActiveDirectory -ErrorAction Stop | Out-Null $servers += Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName } catch { # Ignore AD lookup failures and fallback to local DNS client config. } try { $local = Get-DnsClientServerAddress -AddressFamily IPv4 -ErrorAction Stop | ForEach-Object { $_.ServerAddresses } | Where-Object { $_ } $servers += $local } catch { # Ignore if DnsClient module is unavailable. } return $servers | Sort-Object -Unique } function Ensure-ARecord { param( [string]$Zone, [string]$HostLabel, [string]$TargetIp, [string]$DnsServer, [scriptblock]$Log ) $existing = Get-DnsServerResourceRecord -ZoneName $Zone -Name $HostLabel -RRType "A" -ComputerName $DnsServer -ErrorAction SilentlyContinue if ($existing) { $record = $existing | Select-Object -First 1 $currentIp = $record.RecordData.IPv4Address.ToString() if ($currentIp -eq $TargetIp) { & $Log "DNS A record exists: $HostLabel.$Zone -> $TargetIp" return } $newRecord = $record.Clone() $newRecord.RecordData.IPv4Address = [ipaddress]$TargetIp Set-DnsServerResourceRecord -ZoneName $Zone -OldInputObject $record -NewInputObject $newRecord -ComputerName $DnsServer & $Log "DNS A record updated: $HostLabel.$Zone -> $TargetIp" return } Add-DnsServerResourceRecordA -Name $HostLabel -ZoneName $Zone -IPv4Address $TargetIp -ComputerName $DnsServer & $Log "DNS A record added: $HostLabel.$Zone -> $TargetIp" } function Invoke-Replication { param( [string[]]$Servers, [string]$Command, [scriptblock]$Log ) if ([string]::IsNullOrWhiteSpace($Command)) { return } $targets = @($Servers | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($targets.Count -eq 0) { & $Log "Replication skipped: no target servers." return } foreach ($server in $targets) { $cmd = if ($Command -match "\{server\}") { $Command.Replace("{server}", $server) } else { $Command } $cmd = $cmd.Trim() if ([string]::IsNullOrWhiteSpace($cmd)) { continue } & $Log "Replication: $cmd" & $env:ComSpec /c $cmd | ForEach-Object { & $Log $_ } } } function Invoke-Wacs { param( [string]$WacsPath, [string[]]$HostFqdns, [string]$OutputType, [string]$OutputPath, [string]$PfxPassword, [string]$BaseUri, [string]$Validation, [string]$ValidationPort, [bool]$Verbose, [scriptblock]$Log ) $args = @("--target", "manual") foreach ($hostName in $HostFqdns) { if (-not [string]::IsNullOrWhiteSpace($hostName)) { $args += @("--host", $hostName) } } if ($OutputType -eq "PEM") { $args += @("--store", "pemfiles", "--pemfilespath", $OutputPath) } else { $args += @("--store", "pfxfile", "--pfxfilepath", $OutputPath) if (-not [string]::IsNullOrWhiteSpace($PfxPassword)) { $args += @("--pfxpassword", $PfxPassword) } } $args += @( "--baseuri", $BaseUri, "--validation", $Validation, "--validationport", $ValidationPort ) if ($Verbose) { $args += "--verbose" } & $Log "WACS: $WacsPath $($args -join ' ')" & $WacsPath @args } $form = New-Object System.Windows.Forms.Form $form.Text = "Certy - WACS Helper" $form.Size = [System.Drawing.Size]::new(1000, 860) $form.StartPosition = "CenterScreen" $form.AutoScaleMode = "Dpi" $colorBg = [System.Drawing.Color]::FromArgb(245, 246, 248) $colorPanel = [System.Drawing.Color]::FromArgb(255, 255, 255) $colorText = [System.Drawing.Color]::FromArgb(30, 37, 45) $colorMuted = [System.Drawing.Color]::FromArgb(90, 98, 110) $colorAccent = [System.Drawing.Color]::FromArgb(32, 46, 77) $colorAccentSoft = [System.Drawing.Color]::FromArgb(41, 58, 96) $colorBorder = [System.Drawing.Color]::FromArgb(220, 224, 230) $colorInput = [System.Drawing.Color]::FromArgb(255, 255, 255) $form.BackColor = $colorBg $sidebarWidth = 170 $sidebar = New-Object System.Windows.Forms.Panel $sidebar.Dock = "Left" $sidebar.Width = $sidebarWidth $sidebar.BackColor = $colorAccent $panel = New-Object System.Windows.Forms.Panel $panel.Dock = "Fill" $panel.AutoScroll = $true $panel.BackColor = $colorBg $form.Controls.Add($panel) $form.Controls.Add($sidebar) $font = New-Object System.Drawing.Font("Segoe UI", 9) $labelWidth = 200 $inputWidth = 720 $xLabel = 20 $xInput = 230 $y = 20 $rowHeight = 24 $gap = 8 $leftMargin = 20 $rightMargin = 20 $buttonWidth = 110 $buttonGap = 10 $actionButtonWidth = 130 $navTitle = New-Object System.Windows.Forms.Label $navTitle.Text = "CERTY" $navTitle.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 12) $navTitle.ForeColor = [System.Drawing.Color]::White $navTitle.Location = [System.Drawing.Point]::new(16, 16) $navTitle.Size = [System.Drawing.Size]::new(130, 24) $sidebar.Controls.Add($navTitle) $navSub = New-Object System.Windows.Forms.Label $navSub.Text = "Enterprise Console" $navSub.Font = New-Object System.Drawing.Font("Segoe UI", 8) $navSub.ForeColor = [System.Drawing.Color]::FromArgb(200, 214, 240) $navSub.Location = [System.Drawing.Point]::new(16, 38) $navSub.Size = [System.Drawing.Size]::new(140, 18) $sidebar.Controls.Add($navSub) $navItems = @("Input", "DNS", "ACME", "Run", "Logs") $navY = 80 foreach ($item in $navItems) { $navLabel = New-Object System.Windows.Forms.Label $navLabel.Text = $item $navLabel.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 9) $navLabel.ForeColor = [System.Drawing.Color]::FromArgb(210, 220, 236) $navLabel.Location = [System.Drawing.Point]::new(16, $navY) $navLabel.Size = [System.Drawing.Size]::new(140, 20) $sidebar.Controls.Add($navLabel) $navY += 26 } function Style-ButtonPrimary { param([System.Windows.Forms.Button]$Button) $Button.BackColor = $colorAccent $Button.ForeColor = [System.Drawing.Color]::White $Button.FlatStyle = "Flat" $Button.FlatAppearance.BorderSize = 0 $Button.UseVisualStyleBackColor = $false } function Style-ButtonSecondary { param([System.Windows.Forms.Button]$Button) $Button.BackColor = $colorPanel $Button.ForeColor = $colorText $Button.FlatStyle = "Flat" $Button.FlatAppearance.BorderColor = $colorBorder $Button.FlatAppearance.BorderSize = 1 $Button.UseVisualStyleBackColor = $false } function Add-Label { param([string]$Text, [int]$X, [int]$Y, [int]$W, [int]$H) $label = New-Object System.Windows.Forms.Label $label.Text = $Text $label.Location = [System.Drawing.Point]::new($X, $Y) $label.Size = [System.Drawing.Size]::new($W, $H) $label.Font = $font $label.ForeColor = $colorMuted $panel.Controls.Add($label) return $label } function Add-TextBox { param([int]$X, [int]$Y, [int]$W, [int]$H, [bool]$Multiline = $false) $tb = New-Object System.Windows.Forms.TextBox $tb.Location = [System.Drawing.Point]::new($X, $Y) $tb.Size = [System.Drawing.Size]::new($W, $H) $tb.Font = $font $tb.BackColor = $colorInput $tb.ForeColor = $colorText $tb.BorderStyle = "FixedSingle" $tb.Multiline = $Multiline if ($Multiline) { $tb.ScrollBars = "Vertical" } $panel.Controls.Add($tb) return $tb } function Add-CheckBox { param([string]$Text, [int]$X, [int]$Y, [int]$W, [int]$H) $cb = New-Object System.Windows.Forms.CheckBox $cb.Text = $Text $cb.Location = [System.Drawing.Point]::new($X, $Y) $cb.Size = [System.Drawing.Size]::new($W, $H) $cb.Font = $font $cb.ForeColor = $colorText $panel.Controls.Add($cb) return $cb } function Add-SectionHeader { param([string]$Text) $sectionLabel = New-Object System.Windows.Forms.Label $sectionLabel.Text = $Text $sectionLabel.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 10) $sectionLabel.ForeColor = $colorText $sectionLabel.Location = [System.Drawing.Point]::new($xLabel, $script:y) $sectionLabel.Size = [System.Drawing.Size]::new(300, 20) $panel.Controls.Add($sectionLabel) $sectionLine = New-Object System.Windows.Forms.Panel $sectionLine.BackColor = $colorBorder $sectionLine.Location = [System.Drawing.Point]::new($xLabel, ($script:y + 22)) $sectionLine.Size = [System.Drawing.Size]::new(($inputWidth + ($xInput - $xLabel)), 1) $panel.Controls.Add($sectionLine) $script:sectionLines.Add($sectionLine) | Out-Null $script:y += 30 } $sectionLines = New-Object System.Collections.Generic.List[System.Windows.Forms.Panel] $header = New-Object System.Windows.Forms.Panel $header.Location = [System.Drawing.Point]::new($xLabel, $y) $header.Size = [System.Drawing.Size]::new(($inputWidth + ($xInput - $xLabel)), 70) $header.BackColor = $colorPanel $header.BorderStyle = "FixedSingle" $panel.Controls.Add($header) $headerTitle = New-Object System.Windows.Forms.Label $headerTitle.Text = "Certy Enterprise" $headerTitle.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 16) $headerTitle.ForeColor = $colorText $headerTitle.Location = [System.Drawing.Point]::new(12, 10) $headerTitle.Size = [System.Drawing.Size]::new(300, 28) $header.Controls.Add($headerTitle) $headerSub = New-Object System.Windows.Forms.Label $headerSub.Text = "WACS helper for DNS + ACME proxy workflows" $headerSub.Font = New-Object System.Drawing.Font("Segoe UI", 9) $headerSub.ForeColor = $colorMuted $headerSub.Location = [System.Drawing.Point]::new(12, 38) $headerSub.Size = [System.Drawing.Size]::new(600, 20) $header.Controls.Add($headerSub) $y = $header.Bottom + 16 Add-SectionHeader "Input" Add-Label "Hostnames (one per line)" $xLabel $y $labelWidth $rowHeight $hostsBox = Add-TextBox $xInput $y $inputWidth 100 $true $y += 100 + $gap Add-Label "Hostnames file (optional)" $xLabel $y $labelWidth $rowHeight $fileBox = Add-TextBox $xInput $y ($inputWidth - ($buttonWidth + $buttonGap)) $rowHeight $false $browseBtn = New-Object System.Windows.Forms.Button $browseBtn.Text = "Browse" $browseBtn.Location = [System.Drawing.Point]::new(($xInput + $inputWidth - $buttonWidth), ($y - 1)) $browseBtn.Size = [System.Drawing.Size]::new($buttonWidth, 26) $panel.Controls.Add($browseBtn) Style-ButtonSecondary $browseBtn $y += $rowHeight + $gap Add-Label "File preview (first 200 lines)" $xLabel $y $labelWidth $rowHeight $filePreviewBox = Add-TextBox $xInput $y ($inputWidth - ($buttonWidth + $buttonGap)) 80 $true $filePreviewBox.ReadOnly = $true $filePreviewBtn = New-Object System.Windows.Forms.Button $filePreviewBtn.Text = "Preview" $filePreviewBtn.Location = [System.Drawing.Point]::new(($xInput + $inputWidth - $buttonWidth), ($y - 1)) $filePreviewBtn.Size = [System.Drawing.Size]::new($buttonWidth, 26) $panel.Controls.Add($filePreviewBtn) Style-ButtonSecondary $filePreviewBtn $y += 82 + $gap $csrLabel = Add-Label "CSR folder (optional)" $xLabel $y $labelWidth $rowHeight $csrFolderBox = Add-TextBox $xInput $y ($inputWidth - ((2 * $buttonWidth) + $buttonGap)) $rowHeight $false $csrBrowseBtn = New-Object System.Windows.Forms.Button $csrBrowseBtn.Text = "Browse" $csrBrowseBtn.Location = [System.Drawing.Point]::new(($xInput + $inputWidth - (2 * $buttonWidth + $buttonGap)), ($y - 1)) $csrBrowseBtn.Size = [System.Drawing.Size]::new($buttonWidth, 26) $panel.Controls.Add($csrBrowseBtn) Style-ButtonSecondary $csrBrowseBtn $csrImportBtn = New-Object System.Windows.Forms.Button $csrImportBtn.Text = "Import CSR" $csrImportBtn.Location = [System.Drawing.Point]::new(($xInput + $inputWidth - $buttonWidth), ($y - 1)) $csrImportBtn.Size = [System.Drawing.Size]::new($buttonWidth, 26) $panel.Controls.Add($csrImportBtn) Style-ButtonSecondary $csrImportBtn $y += $rowHeight + $gap $useFqdnBox = Add-CheckBox "Input contains FQDNs (otherwise default zone is appended)" $xInput $y $inputWidth $rowHeight $y += $rowHeight + $gap Add-SectionHeader "DNS & Replication" Add-Label "Default DNS zone" $xLabel $y $labelWidth $rowHeight $zoneBox = Add-TextBox $xInput $y $inputWidth $rowHeight $false $zoneBox.Text = "record.domain.govt.nz" $y += $rowHeight + $gap $replicationEnabledBox = Add-CheckBox "Enable DNS replication" $xInput $y 200 $rowHeight $replicationEnabledBox.Checked = $true $y += $rowHeight + $gap Add-Label "Target IPv4 for A records" $xLabel $y $labelWidth $rowHeight $ipBox = Add-TextBox $xInput $y ($inputWidth - ($buttonWidth + $buttonGap)) $rowHeight $false $ipBox.Text = Get-LocalIpv4 $ipRefreshBtn = New-Object System.Windows.Forms.Button $ipRefreshBtn.Text = "Use Local" $ipRefreshBtn.Location = [System.Drawing.Point]::new(($xInput + $inputWidth - $buttonWidth), ($y - 1)) $ipRefreshBtn.Size = [System.Drawing.Size]::new($buttonWidth, 26) $panel.Controls.Add($ipRefreshBtn) Style-ButtonSecondary $ipRefreshBtn $y += $rowHeight + $gap Add-Label "Primary DNS server" $xLabel $y $labelWidth $rowHeight $dnsServerBox = New-Object System.Windows.Forms.ComboBox $dnsServerBox.Location = [System.Drawing.Point]::new($xInput, $y) $dnsServerBox.Size = [System.Drawing.Size]::new(($inputWidth - ($buttonWidth + $buttonGap)), $rowHeight) $dnsServerBox.DropDownStyle = "DropDown" $dnsServerBox.Text = "DC01.example.local" $dnsServerBox.FlatStyle = "Flat" $dnsServerBox.BackColor = $colorInput $dnsServerBox.ForeColor = $colorText $panel.Controls.Add($dnsServerBox) $dnsScanBtn = New-Object System.Windows.Forms.Button $dnsScanBtn.Text = "Scan" $dnsScanBtn.Location = [System.Drawing.Point]::new(($xInput + $inputWidth - $buttonWidth), ($y - 1)) $dnsScanBtn.Size = [System.Drawing.Size]::new($buttonWidth, 26) $panel.Controls.Add($dnsScanBtn) Style-ButtonSecondary $dnsScanBtn $y += $rowHeight + $gap Add-Label "DNS servers (select for replication)" $xLabel $y $labelWidth $rowHeight $dnsListBox = New-Object System.Windows.Forms.ListBox $dnsListBox.Location = [System.Drawing.Point]::new($xInput, $y) $dnsListBox.Size = [System.Drawing.Size]::new($inputWidth, 80) $dnsListBox.SelectionMode = "MultiExtended" $dnsListBox.BackColor = $colorInput $dnsListBox.ForeColor = $colorText $dnsListBox.BorderStyle = "FixedSingle" $panel.Controls.Add($dnsListBox) $y += 82 + $gap Add-Label "Replication targets (one per line)" $xLabel $y $labelWidth $rowHeight $replicationTargetsBox = Add-TextBox $xInput $y ($inputWidth - ((2 * $buttonWidth) + $buttonGap)) 70 $true $replicationFromSelectedBtn = New-Object System.Windows.Forms.Button $replicationFromSelectedBtn.Text = "Use Selected" $replicationFromSelectedBtn.Location = [System.Drawing.Point]::new(($xInput + $inputWidth - (2 * $buttonWidth + $buttonGap)), ($y - 1)) $replicationFromSelectedBtn.Size = [System.Drawing.Size]::new($buttonWidth, 26) $panel.Controls.Add($replicationFromSelectedBtn) Style-ButtonSecondary $replicationFromSelectedBtn $primaryFromSelectedBtn = New-Object System.Windows.Forms.Button $primaryFromSelectedBtn.Text = "Use Primary" $primaryFromSelectedBtn.Location = [System.Drawing.Point]::new(($xInput + $inputWidth - $buttonWidth), ($y - 1)) $primaryFromSelectedBtn.Size = [System.Drawing.Size]::new($buttonWidth, 26) $panel.Controls.Add($primaryFromSelectedBtn) Style-ButtonSecondary $primaryFromSelectedBtn $y += 70 + $gap Add-Label "Replication command ({server} optional)" $xLabel $y $labelWidth $rowHeight $replicationCmdBox = Add-TextBox $xInput $y $inputWidth $rowHeight $false $replicationCmdBox.Text = "repadmin /syncall {server} /A /e /P /d" $y += $rowHeight + ($gap * 2) Add-SectionHeader "ACME / Output" Add-Label "WACS path" $xLabel $y $labelWidth $rowHeight $wacsPathBox = Add-TextBox $xInput $y $inputWidth $rowHeight $false $wacsPathBox.Text = "C:\ProgramData\Wacs\wacs.exe" $y += $rowHeight + $gap Add-Label "Output type" $xLabel $y $labelWidth $rowHeight $outputTypeBox = New-Object System.Windows.Forms.ComboBox $outputTypeBox.Location = [System.Drawing.Point]::new($xInput, $y) $outputTypeBox.Size = [System.Drawing.Size]::new($inputWidth, $rowHeight) $outputTypeBox.DropDownStyle = "DropDownList" $outputTypeBox.FlatStyle = "Flat" $outputTypeBox.BackColor = $colorInput $outputTypeBox.ForeColor = $colorText [void]$outputTypeBox.Items.Add("PFX") [void]$outputTypeBox.Items.Add("PEM") $outputTypeBox.SelectedIndex = 0 $panel.Controls.Add($outputTypeBox) $y += $rowHeight + $gap $outputPathLabel = Add-Label "PFX output path" $xLabel $y $labelWidth $rowHeight $outputPathBox = Add-TextBox $xInput $y $inputWidth $rowHeight $false $outputPathBox.Text = "C:\programdata\wacs\output\" $y += $rowHeight + $gap $pfxPasswordLabel = Add-Label "PFX password" $xLabel $y $labelWidth $rowHeight $pfxPasswordBox = Add-TextBox $xInput $y $inputWidth $rowHeight $false $pfxPasswordBox.UseSystemPasswordChar = $true $y += $rowHeight + $gap Add-Label "ACME base URI" $xLabel $y $labelWidth $rowHeight $baseUriBox = Add-TextBox $xInput $y $inputWidth $rowHeight $false $baseUriBox.Text = "https://acmeprod.wd.govt.nz:9999/acme/rsa/" $y += $rowHeight + $gap Add-Label "Validation method" $xLabel $y $labelWidth $rowHeight $validationBox = Add-TextBox $xInput $y $inputWidth $rowHeight $false $validationBox.Text = "selfhosting" $y += $rowHeight + $gap Add-Label "Validation port" $xLabel $y $labelWidth $rowHeight $validationPortBox = Add-TextBox $xInput $y $inputWidth $rowHeight $false $validationPortBox.Text = "9998" $y += $rowHeight + $gap Add-SectionHeader "Run" $verboseBox = Add-CheckBox "Verbose" $xInput $y 120 $rowHeight $runWacsBox = Add-CheckBox "Run WACS after DNS update" ($xInput + 140) $y 260 $rowHeight $perHostBox = Add-CheckBox "One cert per host" ($xInput + 430) $y 180 $rowHeight $runWacsBox.Checked = $true $y += $rowHeight + ($gap * 2) $runBtn = New-Object System.Windows.Forms.Button $runBtn.Text = "Run" $runBtn.Location = [System.Drawing.Point]::new($xInput, $y) $runBtn.Size = [System.Drawing.Size]::new(120, 30) $panel.Controls.Add($runBtn) Style-ButtonPrimary $runBtn $clearBtn = New-Object System.Windows.Forms.Button $clearBtn.Text = "Clear Log" $clearBtn.Location = [System.Drawing.Point]::new(($xInput + 140), $y) $clearBtn.Size = [System.Drawing.Size]::new($actionButtonWidth, 30) $panel.Controls.Add($clearBtn) Style-ButtonSecondary $clearBtn $saveDefaultsBtn = New-Object System.Windows.Forms.Button $saveDefaultsBtn.Text = "Save Defaults" $saveDefaultsBtn.Location = [System.Drawing.Point]::new(($xInput + 140 + $actionButtonWidth + $buttonGap), $y) $saveDefaultsBtn.Size = [System.Drawing.Size]::new($actionButtonWidth, 30) $panel.Controls.Add($saveDefaultsBtn) Style-ButtonSecondary $saveDefaultsBtn $y += 40 Add-SectionHeader "Activity" Add-Label "Activity log" $xLabel $y $labelWidth $rowHeight $logBox = Add-TextBox $xInput $y $inputWidth 200 $true $logBox.ReadOnly = $true $logAction = { param([string]$Message) $timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") $logBox.AppendText("[$timestamp] $Message`r`n") } function Update-OutputTypeUI { if ($outputTypeBox.SelectedItem -eq "PEM") { $outputPathLabel.Text = "PEM output path" $pfxPasswordLabel.Visible = $false $pfxPasswordBox.Visible = $false $pfxPasswordBox.Text = "" } else { $outputPathLabel.Text = "PFX output path" $pfxPasswordLabel.Visible = $true $pfxPasswordBox.Visible = $true } } function Update-ReplicationUI { $enabled = $replicationEnabledBox.Checked $replicationTargetsBox.Enabled = $enabled $replicationFromSelectedBtn.Enabled = $enabled $primaryFromSelectedBtn.Enabled = $enabled $replicationCmdBox.Enabled = $enabled $dnsListBox.Enabled = $enabled } $loadedDefaults = Load-Defaults if ($loadedDefaults) { if ($loadedDefaults.DefaultZone) { $zoneBox.Text = $loadedDefaults.DefaultZone } if ($loadedDefaults.TargetIp) { $ipBox.Text = $loadedDefaults.TargetIp } if ($loadedDefaults.DnsServer) { $dnsServerBox.Text = $loadedDefaults.DnsServer } if ($loadedDefaults.ReplicationTargets) { $replicationTargetsBox.Text = $loadedDefaults.ReplicationTargets } if ($loadedDefaults.ReplicationCommand) { $replicationCmdBox.Text = $loadedDefaults.ReplicationCommand } if ($loadedDefaults.PSObject.Properties["ReplicationEnabled"]) { $replicationEnabledBox.Checked = [bool]$loadedDefaults.ReplicationEnabled } if ($loadedDefaults.WacsPath) { $wacsPathBox.Text = $loadedDefaults.WacsPath } if ($loadedDefaults.OutputPath) { $outputPathBox.Text = $loadedDefaults.OutputPath } if ($loadedDefaults.PfxPassword) { $pfxPasswordBox.Text = $loadedDefaults.PfxPassword } if ($loadedDefaults.BaseUri) { $baseUriBox.Text = $loadedDefaults.BaseUri } if ($loadedDefaults.Validation) { $validationBox.Text = $loadedDefaults.Validation } if ($loadedDefaults.ValidationPort) { $validationPortBox.Text = $loadedDefaults.ValidationPort } if ($null -ne $loadedDefaults.UseProvidedFqdn) { $useFqdnBox.Checked = [bool]$loadedDefaults.UseProvidedFqdn } if ($null -ne $loadedDefaults.RunWacs) { $runWacsBox.Checked = [bool]$loadedDefaults.RunWacs } if ($null -ne $loadedDefaults.Verbose) { $verboseBox.Checked = [bool]$loadedDefaults.Verbose } if ($null -ne $loadedDefaults.PerHostCerts) { $perHostBox.Checked = [bool]$loadedDefaults.PerHostCerts } if ($loadedDefaults.OutputType) { $outputTypeBox.SelectedItem = $loadedDefaults.OutputType } if (-not $outputTypeBox.SelectedItem) { $outputTypeBox.SelectedIndex = 0 } Update-OutputTypeUI if (Test-Path function:Update-ReplicationUI) { Update-ReplicationUI } & $logAction "Defaults loaded from $(Get-DefaultsPath)." } function Apply-Layout { if ($panel.ClientSize.Width -le 0) { return } $contentWidth = $panel.ClientSize.Width - $leftMargin - $rightMargin $inputWidthCalc = [Math]::Max(420, ($contentWidth - ($xInput - $xLabel))) $script:inputWidth = $inputWidthCalc $header.Width = $contentWidth foreach ($line in $sectionLines) { $line.Width = $contentWidth } $hostsBox.Width = $inputWidthCalc $fileBox.Width = $inputWidthCalc - ($buttonWidth + $buttonGap) $browseBtn.Left = $xInput + $inputWidthCalc - $buttonWidth $filePreviewBox.Width = $inputWidthCalc - ($buttonWidth + $buttonGap) $filePreviewBtn.Left = $xInput + $inputWidthCalc - $buttonWidth $csrFolderBox.Width = $inputWidthCalc - ((2 * $buttonWidth) + $buttonGap) $csrBrowseBtn.Left = $xInput + $inputWidthCalc - ((2 * $buttonWidth) + $buttonGap) $csrImportBtn.Left = $xInput + $inputWidthCalc - $buttonWidth $zoneBox.Width = $inputWidthCalc $ipBox.Width = $inputWidthCalc - ($buttonWidth + $buttonGap) $ipRefreshBtn.Left = $xInput + $inputWidthCalc - $buttonWidth $dnsServerBox.Width = $inputWidthCalc - ($buttonWidth + $buttonGap) $dnsScanBtn.Left = $xInput + $inputWidthCalc - $buttonWidth $dnsListBox.Width = $inputWidthCalc $replicationTargetsBox.Width = $inputWidthCalc - ((2 * $buttonWidth) + $buttonGap) $replicationFromSelectedBtn.Left = $xInput + $inputWidthCalc - ((2 * $buttonWidth) + $buttonGap) $primaryFromSelectedBtn.Left = $xInput + $inputWidthCalc - $buttonWidth $replicationCmdBox.Width = $inputWidthCalc $wacsPathBox.Width = $inputWidthCalc $outputTypeBox.Width = $inputWidthCalc $outputPathBox.Width = $inputWidthCalc $pfxPasswordBox.Width = $inputWidthCalc $baseUriBox.Width = $inputWidthCalc $validationBox.Width = $inputWidthCalc $validationPortBox.Width = $inputWidthCalc $logBox.Width = $inputWidthCalc $clearBtn.Left = $xInput + 140 $saveDefaultsBtn.Left = $clearBtn.Left + $clearBtn.Width + $buttonGap } $browseBtn.Add_Click({ $dialog = New-Object System.Windows.Forms.OpenFileDialog $dialog.Filter = "Text/CSV files (*.txt;*.csv)|*.txt;*.csv|All files (*.*)|*.*" $dialog.Multiselect = $false if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $fileBox.Text = $dialog.FileName $filePreviewBox.Text = Get-FilePreview -Path $dialog.FileName $fileHosts = Get-Content -Path $dialog.FileName | ForEach-Object { $_.Trim() } | Where-Object { $_ } $zoneGuess = Get-CommonZoneFromHosts -Hosts $fileHosts if ($zoneGuess) { $zoneBox.Text = $zoneGuess & $logAction "Default DNS zone set to $zoneGuess (from file)." } } }) $replicationEnabledBox.Add_CheckedChanged({ if (Test-Path function:Update-ReplicationUI) { Update-ReplicationUI } }) $filePreviewBtn.Add_Click({ $filePreviewBox.Text = Get-FilePreview -Path $fileBox.Text.Trim() if (Test-Path -Path $fileBox.Text.Trim() -PathType Leaf) { $fileHosts = Get-Content -Path $fileBox.Text.Trim() | ForEach-Object { $_.Trim() } | Where-Object { $_ } $zoneGuess = Get-CommonZoneFromHosts -Hosts $fileHosts if ($zoneGuess) { $zoneBox.Text = $zoneGuess & $logAction "Default DNS zone set to $zoneGuess (from file)." } } }) $csrBrowseBtn.Add_Click({ $dialog = New-Object System.Windows.Forms.FolderBrowserDialog if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $csrFolderBox.Text = $dialog.SelectedPath } }) $csrImportBtn.Add_Click({ try { $folder = $csrFolderBox.Text.Trim() if (-not $folder) { throw "CSR folder is empty." } if (-not (Test-Path -Path $folder -PathType Container)) { throw "CSR folder not found: $folder" } $csrFiles = Get-ChildItem -Path $folder -Include *.csr, *.pem -File -Recurse if (-not $csrFiles) { & $logAction "No CSR files found in $folder" return } $csrHosts = $csrFiles | ForEach-Object { $_.BaseName.Trim() } | Where-Object { $_ } $currentHosts = Split-List $hostsBox.Text $merged = Merge-Hostnames -Existing $currentHosts -NewItems $csrHosts $hostsBox.Text = ($merged -join [Environment]::NewLine) & $logAction "Imported $($csrHosts.Count) CSR hostname(s)." } catch { & $logAction "Error: $($_.Exception.Message)" } }) $dnsScanBtn.Add_Click({ try { $dnsListBox.Items.Clear() $servers = Get-DnsServerCandidates if (-not $servers -or $servers.Count -eq 0) { & $logAction "No DNS servers found. Enter servers manually." return } foreach ($server in $servers) { [void]$dnsListBox.Items.Add($server) } $dnsServerBox.Items.Clear() foreach ($server in $servers) { [void]$dnsServerBox.Items.Add($server) } if ($dnsServerBox.Items.Count -gt 0) { $dnsServerBox.SelectedIndex = 0 } & $logAction "Loaded $($servers.Count) DNS server(s)." } catch { & $logAction "Error: $($_.Exception.Message)" } }) $ipRefreshBtn.Add_Click({ $ipBox.Text = Get-LocalIpv4 }) $replicationFromSelectedBtn.Add_Click({ $selected = @($dnsListBox.SelectedItems | ForEach-Object { $_.ToString() }) if ($selected.Count -eq 0) { & $logAction "No DNS servers selected." return } $replicationTargetsBox.Text = ($selected -join [Environment]::NewLine) & $logAction "Replication targets set from selected DNS servers." }) $primaryFromSelectedBtn.Add_Click({ $selected = @($dnsListBox.SelectedItems | ForEach-Object { $_.ToString() }) if ($selected.Count -eq 0) { & $logAction "No DNS servers selected." return } $dnsServerBox.Text = $selected[0] & $logAction "Primary DNS server set to $($selected[0])." }) $outputTypeBox.Add_SelectedIndexChanged({ Update-OutputTypeUI }) $saveDefaultsBtn.Add_Click({ $defaults = [pscustomobject]@{ DefaultZone = $zoneBox.Text TargetIp = $ipBox.Text DnsServer = $dnsServerBox.Text ReplicationTargets = $replicationTargetsBox.Text ReplicationCommand = $replicationCmdBox.Text ReplicationEnabled = $replicationEnabledBox.Checked WacsPath = $wacsPathBox.Text OutputType = $outputTypeBox.SelectedItem.ToString() OutputPath = $outputPathBox.Text PfxPassword = $pfxPasswordBox.Text BaseUri = $baseUriBox.Text Validation = $validationBox.Text ValidationPort = $validationPortBox.Text UseProvidedFqdn = $useFqdnBox.Checked RunWacs = $runWacsBox.Checked Verbose = $verboseBox.Checked PerHostCerts = $perHostBox.Checked } Save-Defaults -Defaults $defaults & $logAction "Defaults saved to $(Get-DefaultsPath)." if (-not [string]::IsNullOrWhiteSpace($pfxPasswordBox.Text)) { & $logAction "Warning: PFX password is stored in plaintext." } }) $clearBtn.Add_Click({ $logBox.Clear() }) $runBtn.Add_Click({ $runBtn.Enabled = $false try { $hosts = @() $hosts += Split-List $hostsBox.Text if (-not [string]::IsNullOrWhiteSpace($fileBox.Text)) { if (-not (Test-Path -Path $fileBox.Text -PathType Leaf)) { throw "Hostnames file not found: $($fileBox.Text)" } $fileHosts = Get-Content -Path $fileBox.Text | ForEach-Object { $_.Trim() } | Where-Object { $_ } $hosts += $fileHosts } if ($hosts.Count -eq 0) { throw "No hostnames provided." } $zone = $zoneBox.Text.Trim() if (-not $zone) { throw "Default DNS zone is required." } $targetIp = $ipBox.Text.Trim() if (-not $targetIp) { throw "Target IPv4 is required." } $selectedReplicationTargets = @($dnsListBox.SelectedItems | ForEach-Object { $_.ToString() }) $dnsServer = $dnsServerBox.Text.Trim() if (-not $dnsServer -and $selectedReplicationTargets.Count -gt 0) { $dnsServer = $selectedReplicationTargets[0] } if (-not $dnsServer) { throw "Primary DNS server is required." } $outputType = $outputTypeBox.SelectedItem.ToString() $outputPath = $outputPathBox.Text.Trim() if (-not $outputPath) { throw "Output path is required." } if ($outputType -eq "PFX" -and [string]::IsNullOrWhiteSpace($pfxPasswordBox.Text)) { throw "PFX password is required." } $hostEntries = $hosts | ForEach-Object { Resolve-HostEntry -Name $_ -Zone $zone -UseProvidedFqdn $useFqdnBox.Checked } | Where-Object { $_ } & $logAction "Processing $($hostEntries.Count) hostname(s)." foreach ($entry in $hostEntries) { Ensure-ARecord -Zone $zone -HostLabel $entry.HostLabel -TargetIp $targetIp -DnsServer $dnsServer -Log $logAction } if ($replicationEnabledBox.Checked) { if ($selectedReplicationTargets.Count -gt 0) { $replicationTargets = $selectedReplicationTargets } else { $replicationTargets = Split-List $replicationTargetsBox.Text } Invoke-Replication -Servers $replicationTargets -Command $replicationCmdBox.Text -Log $logAction } else { & $logAction "Replication disabled." } if ($runWacsBox.Checked) { $wacsPath = $wacsPathBox.Text.Trim() if (-not (Test-Path -Path $wacsPath -PathType Leaf)) { throw "WACS not found at: $wacsPath" } if ($perHostBox.Checked) { foreach ($entry in $hostEntries) { & $logAction "Requesting certificate for $($entry.Fqdn)." Invoke-Wacs ` -WacsPath $wacsPath ` -HostFqdns @($entry.Fqdn) ` -OutputType $outputType ` -OutputPath $outputPath ` -PfxPassword $pfxPasswordBox.Text ` -BaseUri $baseUriBox.Text.Trim() ` -Validation $validationBox.Text.Trim() ` -ValidationPort $validationPortBox.Text.Trim() ` -Verbose $verboseBox.Checked ` -Log $logAction } } else { $hostList = $hostEntries | ForEach-Object { $_.Fqdn } & $logAction "Requesting one certificate with $($hostList.Count) hostname(s)." Invoke-Wacs ` -WacsPath $wacsPath ` -HostFqdns $hostList ` -OutputType $outputType ` -OutputPath $outputPath ` -PfxPassword $pfxPasswordBox.Text ` -BaseUri $baseUriBox.Text.Trim() ` -Validation $validationBox.Text.Trim() ` -ValidationPort $validationPortBox.Text.Trim() ` -Verbose $verboseBox.Checked ` -Log $logAction } } & $logAction "Done." } catch { & $logAction "Error: $($_.Exception.Message)" } finally { $runBtn.Enabled = $true } }) [void]$form.Add_Load({ Apply-Layout }) [void]$form.Add_Shown({ $form.BeginInvoke([Action]{ Apply-Layout }) }) [void]$panel.Add_SizeChanged({ Apply-Layout }) [void]$form.Add_Resize({ Apply-Layout }) [void]$form.ShowDialog()