Files
cert-management/certy.ps1
2026-01-29 14:43:57 +13:00

1498 lines
57 KiB
PowerShell

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
function New-RoundedRectPath {
param(
[System.Drawing.RectangleF]$Rect,
[float]$Radius
)
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$diameter = $Radius * 2
$path.AddArc($Rect.X, $Rect.Y, $diameter, $diameter, 180, 90) | Out-Null
$path.AddArc($Rect.Right - $diameter, $Rect.Y, $diameter, $diameter, 270, 90) | Out-Null
$path.AddArc($Rect.Right - $diameter, $Rect.Bottom - $diameter, $diameter, $diameter, 0, 90) | Out-Null
$path.AddArc($Rect.X, $Rect.Bottom - $diameter, $diameter, $diameter, 90, 90) | Out-Null
$path.CloseFigure()
return $path
}
function New-CertyLogoBitmap {
param([int]$Size = 64)
$bmp = New-Object System.Drawing.Bitmap($Size, $Size)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = "AntiAlias"
$g.Clear([System.Drawing.Color]::Transparent)
$accent = [System.Drawing.Color]::FromArgb(32, 46, 77)
$accentSoft = [System.Drawing.Color]::FromArgb(41, 58, 96)
$paper = [System.Drawing.Color]::FromArgb(255, 255, 255)
$border = [System.Drawing.Color]::FromArgb(210, 214, 222)
$pad = [Math]::Floor($Size * 0.14)
$rect = New-Object System.Drawing.RectangleF($pad, $pad, ($Size - ($pad * 2)), ($Size - ($pad * 2)))
$radius = [Math]::Max(4, [Math]::Floor($Size * 0.1))
$path = New-RoundedRectPath -Rect $rect -Radius $radius
$g.FillPath((New-Object System.Drawing.SolidBrush($paper)), $path)
$g.DrawPath((New-Object System.Drawing.Pen($border, 1.5)), $path)
$sealSize = [Math]::Floor($Size * 0.26)
$sealX = $rect.Right - $sealSize - ($pad * 0.2)
$sealY = $rect.Bottom - $sealSize - ($pad * 0.2)
$sealRect = New-Object System.Drawing.RectangleF($sealX, $sealY, $sealSize, $sealSize)
$g.FillEllipse((New-Object System.Drawing.SolidBrush($accent)), $sealRect)
$tri1 = @(
[System.Drawing.PointF]::new($sealRect.X + ($sealSize * 0.18), $sealRect.Bottom + 2),
[System.Drawing.PointF]::new($sealRect.X + ($sealSize * 0.42), $sealRect.Bottom + 2),
[System.Drawing.PointF]::new($sealRect.X + ($sealSize * 0.30), $sealRect.Bottom + ($sealSize * 0.28))
)
$tri2 = @(
[System.Drawing.PointF]::new($sealRect.X + ($sealSize * 0.58), $sealRect.Bottom + 2),
[System.Drawing.PointF]::new($sealRect.X + ($sealSize * 0.82), $sealRect.Bottom + 2),
[System.Drawing.PointF]::new($sealRect.X + ($sealSize * 0.70), $sealRect.Bottom + ($sealSize * 0.28))
)
$g.FillPolygon((New-Object System.Drawing.SolidBrush($accentSoft)), $tri1)
$g.FillPolygon((New-Object System.Drawing.SolidBrush($accentSoft)), $tri2)
$fontSize = [Math]::Floor($Size * 0.36)
$font = New-Object System.Drawing.Font("Segoe UI Semibold", $fontSize, [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Pixel)
$format = New-Object System.Drawing.StringFormat
$format.Alignment = "Center"
$format.LineAlignment = "Center"
$textRect = New-Object System.Drawing.RectangleF($rect.X, $rect.Y, $rect.Width, $rect.Height)
$g.DrawString("C", $font, (New-Object System.Drawing.SolidBrush($accent)), $textRect, $format)
$g.Dispose()
return $bmp
}
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 ([string]::IsNullOrWhiteSpace($Path)) { return "" }
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 Get-ReplicationCredentialPath {
$dir = Join-Path $env:ProgramData "Certy"
return Join-Path $dir "replication-cred.xml"
}
function Load-ReplicationCredential {
$path = Get-ReplicationCredentialPath
if (-not (Test-Path -Path $path -PathType Leaf)) { return $null }
try {
$cred = Import-Clixml -Path $path
if ($cred -is [pscredential]) { return $cred }
} catch {
# Ignore load errors and treat as missing.
}
return $null
}
function Save-ReplicationCredential {
param([pscredential]$Credential)
$path = Get-ReplicationCredentialPath
$dir = Split-Path -Path $path -Parent
if (-not (Test-Path -Path $dir -PathType Container)) {
New-Item -Path $dir -ItemType Directory -Force | Out-Null
}
$Credential | Export-Clixml -Path $path
}
function Get-DefaultValue {
param(
[object]$Defaults,
[string]$Name
)
if ($null -eq $Defaults) { return $null }
$prop = $Defaults.PSObject.Properties[$Name]
if ($null -eq $prop) { return $null }
return $prop.Value
}
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,
[bool]$UseRemote,
[pscredential]$Credential,
[string]$SourceDc,
[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
}
if ([string]::IsNullOrWhiteSpace($SourceDc)) {
& $Log "Replication skipped: source DC is empty."
return
}
$session = $null
try {
if ($UseRemote) {
try {
if ($Credential) {
$session = New-PSSession -ComputerName $SourceDc -Credential $Credential -ErrorAction Stop
} else {
$session = New-PSSession -ComputerName $SourceDc -ErrorAction Stop
}
& $Log "Replication session opened: $SourceDc"
} catch {
& $Log ("Replication session error on {0}: {1}" -f $SourceDc, $_.Exception.Message)
return
}
}
foreach ($server in $targets) {
$usesToken = $Command -match "\{server\}|\{dest\}"
$cmd = $Command.Replace("{server}", $server).Replace("{dest}", $server)
$cmd = $cmd.Trim()
if ($cmd -match "^(?i)\s*/repadmin\b") {
$cmd = $cmd -replace "^(?i)\s*/repadmin\b", "repadmin"
}
if ($UseRemote) {
$taskName = ("OneShot_AD_DNS_Repl_{0}" -f ($server -replace "[^A-Za-z0-9_-]", "_"))
$outFile = ("C:\Windows\Temp\repadmin-{0}.txt" -f $server)
$repadminCmd = $cmd
$repadminCmd = [regex]::Replace($repadminCmd, "\s{2,}", " ").Trim()
if ([string]::IsNullOrWhiteSpace($repadminCmd)) {
& $Log "Replication skipped: empty command for $server."
continue
}
& $Log "Replication (scheduled task): $SourceDc -> $server"
try {
Invoke-Command -Session $session -ScriptBlock {
param($DestDC, $TaskName, $OutFile, $RepadminCmd)
$cmdLine = "cmd.exe /c $RepadminCmd > `"$OutFile`" 2>&1"
schtasks /Create /F /TN $TaskName /RU SYSTEM /SC ONCE /ST 00:00 /TR $cmdLine | Out-Null
schtasks /Run /TN $TaskName | Out-Null
Start-Sleep 6
$output = if (Test-Path $OutFile) { Get-Content $OutFile } else { "No output file found" }
schtasks /Delete /F /TN $TaskName | Out-Null
Remove-Item $OutFile -Force -ErrorAction SilentlyContinue
$output
} -ArgumentList $server, $taskName, $outFile, $repadminCmd | ForEach-Object { & $Log $_ }
} catch {
& $Log ("Replication error on {0}: {1}" -f $server, $_.Exception.Message)
}
continue
}
if (-not $usesToken -and $server -and $cmd -match "(?i)\brepadmin\b" -and $cmd -match "(?i)\bsyncall\b") {
if ($cmd -notmatch "(?i)\\bsyncall\\s+\\S+") {
$cmd = $cmd -replace "(?i)\\bsyncall\\b", "syncall $server"
}
}
if ([string]::IsNullOrWhiteSpace($cmd)) { continue }
& $Log "Replication: $cmd"
& $env:ComSpec /c $cmd | ForEach-Object { & $Log $_ }
}
} finally {
if ($session) {
try { Remove-PSSession -Session $session } catch {}
}
}
}
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(234, 237, 241)
$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
$logoSmall = New-CertyLogoBitmap -Size 32
$logoLarge = New-CertyLogoBitmap -Size 64
$form.Icon = [System.Drawing.Icon]::FromHandle($logoSmall.GetHicon())
$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)
$logoBox = New-Object System.Windows.Forms.PictureBox
$logoBox.Image = $logoLarge
$logoBox.SizeMode = "Zoom"
$logoBox.Location = [System.Drawing.Point]::new(12, 12)
$logoBox.Size = [System.Drawing.Size]::new(40, 40)
$header.Controls.Add($logoBox)
$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(60, 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(60, 38)
$headerSub.Size = [System.Drawing.Size]::new(600, 20)
$header.Controls.Add($headerSub)
$helpBtn = New-Object System.Windows.Forms.Button
$helpBtn.Text = "How to use me"
$helpBtn.Size = [System.Drawing.Size]::new(140, 28)
$helpBtn.Location = [System.Drawing.Point]::new(($header.Width - 152), 20)
$header.Controls.Add($helpBtn)
Style-ButtonSecondary $helpBtn
$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 = "1) 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 (source DC)" $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 = "2) 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 ({dest} optional)" $xLabel $y $labelWidth $rowHeight
$replicationCmdBox = Add-TextBox $xInput $y $inputWidth $rowHeight $false
$replicationCmdBox.Text = "repadmin /syncall {dest} /AdeP"
$y += $rowHeight + $gap
Add-Label "Replication wait (seconds)" $xLabel $y $labelWidth $rowHeight
$replicationDelayBox = Add-TextBox $xInput $y $inputWidth $rowHeight $false
$replicationDelayBox.Text = "30"
$y += $rowHeight + $gap
$replicationRemoteBox = Add-CheckBox "Run replication via scheduled task on source DC" $xInput $y $inputWidth $rowHeight
$replicationRemoteBox.Checked = $true
$y += $rowHeight + $gap
$replicationCredBtn = New-Object System.Windows.Forms.Button
$replicationCredBtn.Text = "Set Replication Credentials"
$replicationCredBtn.Location = [System.Drawing.Point]::new($xInput, $y)
$replicationCredBtn.Size = [System.Drawing.Size]::new(220, 26)
$panel.Controls.Add($replicationCredBtn)
Style-ButtonSecondary $replicationCredBtn
$y += $rowHeight + $gap
$y += $gap
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
$perHostBox = Add-CheckBox "One cert per host" ($xInput + 430) $y 180 $rowHeight
$disableCertsBox = Add-CheckBox "Turn off cert generation (DNS-only mode)" ($xInput + 140) $y 360 $rowHeight
$y += $rowHeight + ($gap * 2)
$runBtn = New-Object System.Windows.Forms.Button
$runBtn.Text = "5) 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 Show-HelpDialog {
$helpForm = New-Object System.Windows.Forms.Form
$helpForm.Text = "How it works"
$helpForm.Size = [System.Drawing.Size]::new(760, 560)
$helpForm.StartPosition = "CenterParent"
$helpForm.BackColor = $colorBg
$panelHelp = New-Object System.Windows.Forms.Panel
$panelHelp.Dock = "Fill"
$panelHelp.BackColor = $colorBg
$panelHelp.Padding = New-Object System.Windows.Forms.Padding(16)
$helpForm.Controls.Add($panelHelp)
$headerPanel = New-Object System.Windows.Forms.Panel
$headerPanel.BackColor = $colorPanel
$headerPanel.BorderStyle = "FixedSingle"
$headerPanel.Size = [System.Drawing.Size]::new(700, 70)
$headerPanel.Location = [System.Drawing.Point]::new(0, 0)
$panelHelp.Controls.Add($headerPanel)
$title = New-Object System.Windows.Forms.Label
$title.Text = "Certy - How it works"
$title.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 14)
$title.ForeColor = $colorText
$title.Location = [System.Drawing.Point]::new(12, 12)
$title.Size = [System.Drawing.Size]::new(500, 24)
$headerPanel.Controls.Add($title)
$subtitle = New-Object System.Windows.Forms.Label
$subtitle.Text = "Follow these steps to create DNS records and issue certificates."
$subtitle.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$subtitle.ForeColor = $colorMuted
$subtitle.Location = [System.Drawing.Point]::new(12, 38)
$subtitle.Size = [System.Drawing.Size]::new(640, 18)
$headerPanel.Controls.Add($subtitle)
$contentPanel = New-Object System.Windows.Forms.Panel
$contentPanel.Location = [System.Drawing.Point]::new(0, 86)
$contentPanel.Size = [System.Drawing.Size]::new(700, 380)
$contentPanel.BackColor = $colorBg
$panelHelp.Controls.Add($contentPanel)
$stepTitle = New-Object System.Windows.Forms.Label
$stepTitle.Text = "Basic steps"
$stepTitle.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 10)
$stepTitle.ForeColor = $colorText
$stepTitle.Location = [System.Drawing.Point]::new(0, 0)
$stepTitle.Size = [System.Drawing.Size]::new(200, 20)
$contentPanel.Controls.Add($stepTitle)
$stepY = 28
$steps = @(
"Add hostnames (one per line, CSV, or CSR folder) then click 1) Preview.",
"Click 2) Scan and select the DNS server (Primary is 10.106.60.1). Replication runs from there.",
"Set the replication wait time. Typical value: 15 minutes.",
"Choose output type (PEM or PFX). If PFX, supply a password.",
"Click 5) Run."
)
$index = 1
foreach ($step in $steps) {
$num = New-Object System.Windows.Forms.Label
$num.Text = "$index."
$num.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 9)
$num.ForeColor = $colorText
$num.Location = [System.Drawing.Point]::new(0, $stepY)
$num.Size = [System.Drawing.Size]::new(24, 20)
$contentPanel.Controls.Add($num)
$text = New-Object System.Windows.Forms.Label
$text.Text = $step
$text.Font = $font
$text.ForeColor = $colorText
$text.Location = [System.Drawing.Point]::new(28, $stepY)
$text.Size = [System.Drawing.Size]::new(660, 32)
$contentPanel.Controls.Add($text)
$stepY += 34
$index++
}
$optionsTitle = New-Object System.Windows.Forms.Label
$optionsTitle.Text = "Options"
$optionsTitle.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 10)
$optionsTitle.ForeColor = $colorText
$optionsTitle.Location = [System.Drawing.Point]::new(0, ($stepY + 10))
$optionsTitle.Size = [System.Drawing.Size]::new(200, 20)
$contentPanel.Controls.Add($optionsTitle)
$optY = $stepY + 36
$options = @(
"Turn off cert generation (DNS-only mode) to add DNS now and generate certs later.",
"Disable DNS replication if records already point correctly and you only need a renewal."
)
foreach ($opt in $options) {
$bullet = New-Object System.Windows.Forms.Label
$bullet.Text = ""
$bullet.Font = $font
$bullet.ForeColor = $colorText
$bullet.Location = [System.Drawing.Point]::new(0, $optY)
$bullet.Size = [System.Drawing.Size]::new(12, 20)
$contentPanel.Controls.Add($bullet)
$optLabel = New-Object System.Windows.Forms.Label
$optLabel.Text = $opt
$optLabel.Font = $font
$optLabel.ForeColor = $colorText
$optLabel.Location = [System.Drawing.Point]::new(16, $optY)
$optLabel.Size = [System.Drawing.Size]::new(660, 32)
$contentPanel.Controls.Add($optLabel)
$optY += 30
}
$closeBtn = New-Object System.Windows.Forms.Button
$closeBtn.Text = "Close"
$closeBtn.Size = [System.Drawing.Size]::new(100, 30)
$closeBtn.Location = [System.Drawing.Point]::new(600, 470)
$panelHelp.Controls.Add($closeBtn)
Style-ButtonSecondary $closeBtn
$closeBtn.Add_Click({ $helpForm.Close() })
$helpForm.ShowDialog() | Out-Null
}
function Update-ReplicationUI {
$enabled = $replicationEnabledBox.Checked
$replicationTargetsBox.Enabled = $enabled
$replicationFromSelectedBtn.Enabled = $enabled
$primaryFromSelectedBtn.Enabled = $enabled
$replicationCmdBox.Enabled = $enabled
$replicationDelayBox.Enabled = $enabled
$replicationRemoteBox.Enabled = $enabled
$replicationCredBtn.Enabled = $enabled
$dnsListBox.Enabled = $enabled
}
function Update-CertGenerationUI {
$disabled = $disableCertsBox.Checked
$perHostBox.Enabled = -not $disabled
$wacsPathBox.Enabled = -not $disabled
$outputTypeBox.Enabled = -not $disabled
$outputPathBox.Enabled = -not $disabled
$pfxPasswordBox.Enabled = -not $disabled
$baseUriBox.Enabled = -not $disabled
$validationBox.Enabled = -not $disabled
$validationPortBox.Enabled = -not $disabled
}
function Update-ZoneFromHostInput {
$hostList = @(Split-List $hostsBox.Text)
if ($hostList.Count -eq 0) { return }
$zoneGuess = Get-CommonZoneFromHosts -Hosts $hostList
if (-not [string]::IsNullOrWhiteSpace($zoneGuess) -and $zoneBox.Text -ne $zoneGuess) {
$zoneBox.Text = $zoneGuess
& $logAction "Default DNS zone set to $zoneGuess (from hostnames)."
}
}
$loadedDefaults = Load-Defaults
if ($loadedDefaults) {
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "DefaultZone"
if (-not [string]::IsNullOrWhiteSpace($value)) { $zoneBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "TargetIp"
if (-not [string]::IsNullOrWhiteSpace($value)) { $ipBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "DnsServer"
if (-not [string]::IsNullOrWhiteSpace($value)) { $dnsServerBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "ReplicationTargets"
if (-not [string]::IsNullOrWhiteSpace($value)) { $replicationTargetsBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "ReplicationCommand"
if (-not [string]::IsNullOrWhiteSpace($value)) { $replicationCmdBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "ReplicationEnabled"
if ($null -ne $value) { $replicationEnabledBox.Checked = [bool]$value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "ReplicationDelaySeconds"
if ($null -ne $value) { $replicationDelayBox.Text = $value.ToString() }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "ReplicationRemote"
if ($null -ne $value) { $replicationRemoteBox.Checked = [bool]$value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "WacsPath"
if (-not [string]::IsNullOrWhiteSpace($value)) { $wacsPathBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "OutputPath"
if (-not [string]::IsNullOrWhiteSpace($value)) { $outputPathBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "PfxPassword"
if (-not [string]::IsNullOrWhiteSpace($value)) { $pfxPasswordBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "BaseUri"
if (-not [string]::IsNullOrWhiteSpace($value)) { $baseUriBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "Validation"
if (-not [string]::IsNullOrWhiteSpace($value)) { $validationBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "ValidationPort"
if (-not [string]::IsNullOrWhiteSpace($value)) { $validationPortBox.Text = $value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "UseProvidedFqdn"
if ($null -ne $value) { $useFqdnBox.Checked = [bool]$value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "Verbose"
if ($null -ne $value) { $verboseBox.Checked = [bool]$value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "PerHostCerts"
if ($null -ne $value) { $perHostBox.Checked = [bool]$value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "DisableCertGeneration"
if ($null -ne $value) { $disableCertsBox.Checked = [bool]$value }
$value = Get-DefaultValue -Defaults $loadedDefaults -Name "OutputType"
if (-not [string]::IsNullOrWhiteSpace($value)) { $outputTypeBox.SelectedItem = $value }
if (-not $outputTypeBox.SelectedItem) { $outputTypeBox.SelectedIndex = 0 }
Update-OutputTypeUI
if (Test-Path function:Update-ReplicationUI) { Update-ReplicationUI }
Update-CertGenerationUI
& $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
}
$helpBtn.Left = $header.Width - $helpBtn.Width - 12
$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
$replicationDelayBox.Width = $inputWidthCalc
$replicationRemoteBox.Width = $inputWidthCalc
$replicationCredBtn.Width = [Math]::Min($inputWidthCalc, 260)
$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({
$path = $fileBox.Text.Trim()
if ([string]::IsNullOrWhiteSpace($path)) {
$previewHosts = @(Split-List $hostsBox.Text)
if ($previewHosts.Count -gt 0) {
$filePreviewBox.Text = ($previewHosts | Select-Object -First 200) -join [Environment]::NewLine
return
}
$filePreviewBox.Text = ""
& $logAction "No file selected and no hostnames to preview."
return
}
$filePreviewBox.Text = Get-FilePreview -Path $path
if (Test-Path -Path $path -PathType Leaf) {
$fileHosts = Get-Content -Path $path | 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
})
$helpBtn.Add_Click({
Show-HelpDialog
})
$replicationCredBtn.Add_Click({
$cred = Get-Credential -Message "Enter credentials for replication sessions."
if (-not $cred) {
& $logAction "Replication credentials not set."
return
}
Save-ReplicationCredential -Credential $cred
& $logAction "Replication credentials saved for this user."
})
$hostsBox.Add_TextChanged({
Update-ZoneFromHostInput
})
$disableCertsBox.Add_CheckedChanged({
Update-CertGenerationUI
})
$saveDefaultsBtn.Add_Click({
$defaults = [pscustomobject]@{
DefaultZone = $zoneBox.Text
TargetIp = $ipBox.Text
DnsServer = $dnsServerBox.Text
ReplicationTargets = $replicationTargetsBox.Text
ReplicationCommand = $replicationCmdBox.Text
ReplicationEnabled = $replicationEnabledBox.Checked
ReplicationDelaySeconds = $replicationDelayBox.Text
ReplicationRemote = $replicationRemoteBox.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
Verbose = $verboseBox.Checked
PerHostCerts = $perHostBox.Checked
DisableCertGeneration = $disableCertsBox.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) {
$replicationDelaySeconds = 0
$delayRaw = $replicationDelayBox.Text.Trim()
if (-not [string]::IsNullOrWhiteSpace($delayRaw)) {
if (-not [int]::TryParse($delayRaw, [ref]$replicationDelaySeconds) -or $replicationDelaySeconds -lt 0) {
throw "Replication wait seconds must be a non-negative integer."
}
}
if ($selectedReplicationTargets.Count -gt 0) {
$replicationTargets = $selectedReplicationTargets
} else {
$replicationTargets = @(Split-List $replicationTargetsBox.Text)
if ($replicationTargets.Count -eq 0 -and $dnsServer) {
$replicationTargets = @($dnsServer)
& $logAction "Replication targets empty; using primary DNS server $dnsServer."
}
}
$replicationCredential = $null
if ($replicationRemoteBox.Checked) {
$replicationCredential = Load-ReplicationCredential
if (-not $replicationCredential) {
$replicationCredential = Get-Credential -Message "Enter credentials for replication targets."
if (-not $replicationCredential) {
& $logAction "Replication canceled: credentials not provided."
return
}
Save-ReplicationCredential -Credential $replicationCredential
& $logAction "Replication credentials saved for this user."
}
}
Invoke-Replication `
-Servers $replicationTargets `
-Command $replicationCmdBox.Text `
-UseRemote $replicationRemoteBox.Checked `
-Credential $replicationCredential `
-SourceDc $dnsServer `
-Log $logAction
if ($replicationDelaySeconds -gt 0) {
& $logAction "Waiting $replicationDelaySeconds seconds for replication."
Start-Sleep -Seconds $replicationDelaySeconds
}
} else {
& $logAction "Replication disabled."
}
if ($disableCertsBox.Checked) {
& $logAction "Cert generation disabled; DNS updates/replication only."
} else {
$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 } | Where-Object { $_ })
& $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({
try { Apply-Layout } catch {}
})
[void]$form.Add_Shown({
try { $form.BeginInvoke([Action]{ try { Apply-Layout } catch {} }) } catch {}
})
[void]$panel.Add_SizeChanged({
try { Apply-Layout } catch {}
})
[void]$form.Add_Resize({
try { Apply-Layout } catch {}
})
[void]$form.Add_FormClosed({
try { $logoBox.Image = $null } catch {}
try { $logoLarge.Dispose() } catch {}
try { $logoSmall.Dispose() } catch {}
})
[void]$form.ShowDialog()