diff --git a/certy.ps1 b/certy.ps1
index 7cd0382..81c44e6 100644
--- a/certy.ps1
+++ b/certy.ps1
@@ -1,1018 +1,816 @@
-Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase, System.Xaml
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
Add-Type -AssemblyName System.Windows.Forms
+Add-Type -AssemblyName System.Drawing
-$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
-$defaultSettingsPath = Join-Path $scriptRoot "certy.settings.json"
-$defaultLogPath = Join-Path $scriptRoot "certy.log"
-
-$xaml = @'
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-'@
-
-$settingsXaml = @'
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-'@
-
-[xml]$xamlDoc = $xaml
-$reader = New-Object System.Xml.XmlNodeReader $xamlDoc
-$window = [Windows.Markup.XamlReader]::Load($reader)
-
-function Get-Control([string]$name) {
- return $window.FindName($name)
+function Split-List {
+ param([string]$Text)
+ if ([string]::IsNullOrWhiteSpace($Text)) { return @() }
+ return $Text -split '[,\r\n;]+' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
}
-$txtSettingsPath = Get-Control "txtSettingsPath"
-$txtLogPath = Get-Control "txtLogPath"
-$btnOpenSettings = Get-Control "btnOpenSettings"
-$rbFlowCsr = Get-Control "rbFlowCsr"
-$rbFlowInf = Get-Control "rbFlowInf"
-$pnlCsrFlow = Get-Control "pnlCsrFlow"
-$pnlInfFlow = Get-Control "pnlInfFlow"
-
-$txtInfInputPath = Get-Control "txtInfInputPath"
-$txtInfOutputDir = Get-Control "txtInfOutputDir"
-$txtInfTemplate = Get-Control "txtInfTemplate"
-$chkInfAdditionalSans = Get-Control "chkInfAdditionalSans"
-$cmbInfTemplatePreset = Get-Control "cmbInfTemplatePreset"
-$cmbInfPrimaryDomain = Get-Control "cmbInfPrimaryDomain"
-$txtInfAdditionalDomains = Get-Control "txtInfAdditionalDomains"
-$btnBrowseInfInput = Get-Control "btnBrowseInfInput"
-$btnBrowseInfOutput = Get-Control "btnBrowseInfOutput"
-$btnRunInf = Get-Control "btnRunInf"
-
-$txtDnsListInputPath = Get-Control "txtDnsListInputPath"
-$cmbCsrDomainSuffix = Get-Control "cmbCsrDomainSuffix"
-$rbCsrFileFqdn = Get-Control "rbCsrFileFqdn"
-$rbCsrFileHost = Get-Control "rbCsrFileHost"
-$btnPreviewCsr = Get-Control "btnPreviewCsr"
-$txtCsrPreview = Get-Control "txtCsrPreview"
-$txtDnsZone = Get-Control "txtDnsZone"
-$txtDnsTargetIp = Get-Control "txtDnsTargetIp"
-$txtDnsServer = Get-Control "txtDnsServer"
-$txtDnsPlaceholderOutput = Get-Control "txtDnsPlaceholderOutput"
-$chkDnsCreatePlaceholders = Get-Control "chkDnsCreatePlaceholders"
-$chkDnsUpdate = Get-Control "chkDnsUpdate"
-$btnBrowseDnsListInput = Get-Control "btnBrowseDnsListInput"
-$btnBrowseDnsPlaceholderOutput = Get-Control "btnBrowseDnsPlaceholderOutput"
-
-$txtWacsPath = Get-Control "txtWacsPath"
-$txtWacsPfxFolder = Get-Control "txtWacsPfxFolder"
-$txtWacsBaseUri = Get-Control "txtWacsBaseUri"
-$txtWacsValidation = Get-Control "txtWacsValidation"
-$txtWacsValidationPort = Get-Control "txtWacsValidationPort"
-$txtWacsTarget = Get-Control "txtWacsTarget"
-$txtWacsStore = Get-Control "txtWacsStore"
-$chkWacsAddSans = Get-Control "chkWacsAddSans"
-$txtWacsSans = Get-Control "txtWacsSans"
-$chkWacsVerbose = Get-Control "chkWacsVerbose"
-$btnBrowseWacsPath = Get-Control "btnBrowseWacsPath"
-$btnBrowseWacsPfxFolder = Get-Control "btnBrowseWacsPfxFolder"
-$btnRunWacs = Get-Control "btnRunWacs"
-
-$txtLogOutput = Get-Control "txtLogOutput"
-$btnClearLog = Get-Control "btnClearLog"
-
-$txtSettingsPath.Text = $defaultSettingsPath
-$txtLogPath.Text = $defaultLogPath
-$script:FqdnListText = ""
-$pnlCsrFlow.Visibility = "Visible"
-$pnlInfFlow.Visibility = "Collapsed"
-
-$defaultTemplate = @'
-[Version]
-Signature="$Windows NT$"
-[NewRequest]
-Subject = "CN=$Placeholder"
-KeySpec = 1
-KeyLength = 2048
-Exportable = TRUE
-MachineKeySet = True
-ProviderName = "Microsoft RSA SChannel Cryptographic Provider"
-HashAlgorithm = sha256
-RequestType = PKCS10
-KeyUsage = 0xa0
-FriendlyName = "$Placeholder"
-[EnhancedKeyUsageExtension]
-OID=1.3.6.1.5.5.7.3.1 ; Server Authentication
-OID=1.3.6.1.5.5.7.3.2 ; Client Authentication
-'@
-
-$legacyTemplate = @'
-[Version]
-Signature="$Windows NT$"
-[NewRequest]
-Subject = "CN=$Placeholder.printer.MBIE.govt.nz;OU=ICT;O=Ministry of Business, Innovation and Employment;L=Wellington;S=Wellington;C=NZ"
-X500NameFlags = 0x40000000
-Exportable = TRUE
-KeyLength = 2048
-KeySpec = 1
-KeyUsage = 0xA0
-MachineKeySet = True
-ProviderName = "Microsoft RSA SChannel Cryptographic Provider"
-HashAlgorithm = sha256
-RequestType = PKCS10
-FriendlyName = "$Placeholder.printer.MBIE.govt.nz - 2026"
-[EnhancedKeyUsageExtension]
-OID=1.3.6.1.5.5.7.3.1 ; Server Authentication
-OID=1.3.6.1.5.5.7.3.2 ; Client Authentication
-'@
-
-$txtInfTemplate.Text = $defaultTemplate
-$chkInfAdditionalSans.IsChecked = $false
-
-$txtDnsListInputPath.Text = ''
-$txtDnsZone.Text = 'record.domain.govt.nz'
-$txtDnsTargetIp.Text = 'managementboxIP'
-$txtDnsServer.Text = 'DC01.example.local'
-$txtDnsPlaceholderOutput.Text = 'C:\Temp\CSRs'
-$chkDnsCreatePlaceholders.IsChecked = $false
-$chkDnsUpdate.IsChecked = $true
-
-$txtWacsPath.Text = 'C:\ProgramData\Wacs\wacs.exe'
-$txtWacsPfxFolder.Text = 'C:\ProgramData\Wacs\output'
-$txtWacsBaseUri.Text = 'https://acmeprod.domain.govt.nz:9999/acme/rsa/'
-$txtWacsValidation.Text = 'selfhosting'
-$txtWacsValidationPort.Text = '9998'
-$txtWacsTarget.Text = 'manual'
-$txtWacsStore.Text = 'pfxfile'
-$chkWacsAddSans.IsChecked = $false
-$txtWacsSans.Text = ""
-$chkWacsVerbose.IsChecked = $true
-
-function Write-Log([string]$message, [string]$level = "INFO") {
- $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
- $line = "{0} [{1}] {2}" -f $timestamp, $level, $message
- $txtLogOutput.AppendText($line + [Environment]::NewLine)
- $txtLogOutput.ScrollToEnd()
-
- $logPath = $txtLogPath.Text.Trim()
- if ([string]::IsNullOrWhiteSpace($logPath)) { return }
+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 {
- $logDir = Split-Path -Parent $logPath
- if ($logDir -and -not (Test-Path $logDir)) {
- New-Item -Path $logDir -ItemType Directory -Force | Out-Null
- }
- Add-Content -Path $logPath -Value $line -Encoding ascii
+ $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 {
- $txtLogOutput.AppendText("Failed to write log file: $($_.Exception.Message)" + [Environment]::NewLine)
- }
-}
-
-function Save-Settings {
- $settingsPath = $txtSettingsPath.Text.Trim()
- if ([string]::IsNullOrWhiteSpace($settingsPath)) {
- Write-Log "Settings path is empty." "ERROR"
- return
- }
-
- $settings = @{
- SettingsPath = $settingsPath
- LogPath = $txtLogPath.Text.Trim()
- FqdnList = $(if ($script:FqdnListText) { $script:FqdnListText } else { "" })
- Inf = @{
- InputPath = $txtInfInputPath.Text.Trim()
- OutputDir = $txtInfOutputDir.Text.Trim()
- Template = $txtInfTemplate.Text
- IncludeAdditionalSans = [bool]$chkInfAdditionalSans.IsChecked
- PrimaryDomain = $cmbInfPrimaryDomain.Text.Trim()
- AdditionalDomains = $txtInfAdditionalDomains.Text
- TemplatePreset = $cmbInfTemplatePreset.Text
- }
- Dns = @{
- ListInputPath = $txtDnsListInputPath.Text.Trim()
- Zone = $txtDnsZone.Text.Trim()
- TargetIp = $txtDnsTargetIp.Text.Trim()
- DnsServer = $txtDnsServer.Text.Trim()
- PlaceholderOutput = $txtDnsPlaceholderOutput.Text.Trim()
- CreatePlaceholders = [bool]$chkDnsCreatePlaceholders.IsChecked
- UpdateDns = [bool]$chkDnsUpdate.IsChecked
- }
- Wacs = @{
- WacsPath = $txtWacsPath.Text.Trim()
- PfxFolder = $txtWacsPfxFolder.Text.Trim()
- BaseUri = $txtWacsBaseUri.Text.Trim()
- Validation = $txtWacsValidation.Text.Trim()
- ValidationPort = $txtWacsValidationPort.Text.Trim()
- Target = $txtWacsTarget.Text.Trim()
- Store = $txtWacsStore.Text.Trim()
- AddSans = [bool]$chkWacsAddSans.IsChecked
- Sans = $txtWacsSans.Text
- Verbose = [bool]$chkWacsVerbose.IsChecked
- }
- SavedAt = (Get-Date).ToString("s")
+ # Ignore and fallback to DNS lookup.
}
try {
- $settingsDir = Split-Path -Parent $settingsPath
- if ($settingsDir -and -not (Test-Path $settingsDir)) {
- New-Item -Path $settingsDir -ItemType Directory -Force | Out-Null
- }
- $json = $settings | ConvertTo-Json -Depth 6
- Set-Content -Path $settingsPath -Value $json -Encoding ascii
- Write-Log "Settings saved to $settingsPath"
+ $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 {
- Write-Log "Failed to save settings: $($_.Exception.Message)" "ERROR"
+ # 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 Load-Settings {
- $settingsPath = $txtSettingsPath.Text.Trim()
- if (-not (Test-Path $settingsPath -PathType Leaf)) {
- Write-Log "Settings file not found: $settingsPath" "ERROR"
- return
- }
+function Resolve-HostEntry {
+ param(
+ [string]$Name,
+ [string]$Zone,
+ [bool]$UseProvidedFqdn
+ )
- try {
- $raw = Get-Content -Path $settingsPath -Raw
- $settings = $raw | ConvertFrom-Json
+ $name = $Name.Trim()
+ if (-not $name) { return $null }
- if ($settings.LogPath) { $txtLogPath.Text = $settings.LogPath }
+ $zoneLower = $Zone.ToLower()
+ $nameLower = $name.ToLower()
- if ($settings.FqdnList) { $script:FqdnListText = $settings.FqdnList }
-
- if ($settings.Inf) {
- $txtInfInputPath.Text = $settings.Inf.InputPath
- $txtInfOutputDir.Text = $settings.Inf.OutputDir
- if ($settings.Inf.Template) { $txtInfTemplate.Text = $settings.Inf.Template }
- if ($settings.Inf.TemplatePreset) { $cmbInfTemplatePreset.Text = $settings.Inf.TemplatePreset }
- if ($settings.Inf.PrimaryDomain) { $cmbInfPrimaryDomain.Text = $settings.Inf.PrimaryDomain }
- if ($settings.Inf.AdditionalDomains) { $txtInfAdditionalDomains.Text = $settings.Inf.AdditionalDomains }
- if ($settings.Inf.PSObject.Properties.Name -contains "IncludeAdditionalSans") {
- $chkInfAdditionalSans.IsChecked = [bool]$settings.Inf.IncludeAdditionalSans
- } elseif ($settings.Inf.PSObject.Properties.Name -contains "UseSimpleTemplate") {
- $chkInfAdditionalSans.IsChecked = -not [bool]$settings.Inf.UseSimpleTemplate
+ 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"
}
+ }
- if ($settings.Dns) {
- if ($settings.Dns.ListInputPath) { $txtDnsListInputPath.Text = $settings.Dns.ListInputPath }
- if ($settings.Dns.Zone) { $txtDnsZone.Text = $settings.Dns.Zone }
- if ($settings.Dns.TargetIp) { $txtDnsTargetIp.Text = $settings.Dns.TargetIp }
- if ($settings.Dns.DnsServer) { $txtDnsServer.Text = $settings.Dns.DnsServer }
- if ($settings.Dns.PlaceholderOutput) { $txtDnsPlaceholderOutput.Text = $settings.Dns.PlaceholderOutput }
- if ($settings.Dns.PSObject.Properties.Name -contains "CreatePlaceholders") { $chkDnsCreatePlaceholders.IsChecked = [bool]$settings.Dns.CreatePlaceholders }
- if ($settings.Dns.PSObject.Properties.Name -contains "UpdateDns") { $chkDnsUpdate.IsChecked = [bool]$settings.Dns.UpdateDns }
- }
+ $fqdnLower = $fqdn.ToLower()
+ if ($fqdnLower.EndsWith(".$zoneLower")) {
+ $hostLabel = $fqdn.Substring(0, $fqdn.Length - $Zone.Length - 1)
+ } elseif ($fqdnLower -eq $zoneLower) {
+ $hostLabel = "@"
+ } else {
+ $hostLabel = $fqdn
+ }
- if ($settings.Wacs) {
- if ($settings.Wacs.WacsPath) { $txtWacsPath.Text = $settings.Wacs.WacsPath }
- if ($settings.Wacs.PfxFolder) { $txtWacsPfxFolder.Text = $settings.Wacs.PfxFolder }
- if ($settings.Wacs.BaseUri) { $txtWacsBaseUri.Text = $settings.Wacs.BaseUri }
- if ($settings.Wacs.Validation) { $txtWacsValidation.Text = $settings.Wacs.Validation }
- if ($settings.Wacs.ValidationPort) { $txtWacsValidationPort.Text = $settings.Wacs.ValidationPort }
- if ($settings.Wacs.Target) { $txtWacsTarget.Text = $settings.Wacs.Target }
- if ($settings.Wacs.Store) { $txtWacsStore.Text = $settings.Wacs.Store }
- if ($settings.Wacs.PSObject.Properties.Name -contains "AddSans") { $chkWacsAddSans.IsChecked = [bool]$settings.Wacs.AddSans }
- if ($settings.Wacs.PSObject.Properties.Name -contains "Sans") { $txtWacsSans.Text = $settings.Wacs.Sans }
- $chkWacsVerbose.IsChecked = [bool]$settings.Wacs.Verbose
- }
-
- Refresh-FqdnLists
-
- Write-Log "Settings loaded from $settingsPath"
- } catch {
- Write-Log "Failed to load settings: $($_.Exception.Message)" "ERROR"
+ return [pscustomobject]@{
+ Input = $name
+ Fqdn = $fqdn
+ HostLabel = $hostLabel
}
}
-function Open-FileDialog([string]$filter = "All files (*.*)|*.*") {
- $dialog = New-Object Microsoft.Win32.OpenFileDialog
- $dialog.Filter = $filter
- $dialog.Multiselect = $false
- if ($dialog.ShowDialog() -eq $true) { return $dialog.FileName }
- return $null
+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 Open-FolderDialog {
+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 = if ($Servers.Count -gt 0) { $Servers } else { @("") }
+
+ foreach ($server in $targets) {
+ $cmd = if ($Command -match "\{server\}") { $Command.Replace("{server}", $server) } else { $Command }
+ if ([string]::IsNullOrWhiteSpace($cmd)) { continue }
+ & $Log "Replication: $cmd"
+ & $env:ComSpec /c $cmd | ForEach-Object { & $Log $_ }
+ }
+}
+
+function Invoke-Wacs {
+ param(
+ [string]$WacsPath,
+ [string]$HostFqdn,
+ [string]$OutputType,
+ [string]$OutputPath,
+ [string]$PfxPassword,
+ [string]$BaseUri,
+ [string]$Validation,
+ [string]$ValidationPort,
+ [bool]$Verbose,
+ [scriptblock]$Log
+ )
+
+ $args = @("--target", "manual", "--host", $HostFqdn)
+
+ 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 = 80
+$buttonGap = 8
+
+$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 - 90) $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(80, 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 - 90) 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(80, 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 - 180) $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(80, 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(80, 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
+
+Add-Label "Target IPv4 for A records" $xLabel $y $labelWidth $rowHeight
+$ipBox = Add-TextBox $xInput $y ($inputWidth - 90) $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(80, 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 - 90), $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(80, 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 - 180) 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(80, 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(80, 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} /AdeP"
+$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
+$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(120, 30)
+$panel.Controls.Add($clearBtn)
+Style-ButtonSecondary $clearBtn
+
+$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 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
+}
+
+$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
+ }
+})
+
+$filePreviewBtn.Add_Click({
+ $filePreviewBox.Text = Get-FilePreview -Path $fileBox.Text.Trim()
+})
+
+$csrBrowseBtn.Add_Click({
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
- return $dialog.SelectedPath
+ $csrFolderBox.Text = $dialog.SelectedPath
}
- return $null
-}
-
-function Get-FqdnSuffixes {
- if ([string]::IsNullOrWhiteSpace($script:FqdnListText)) { return @() }
- return $script:FqdnListText -split "(\r?\n)" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
-}
-
-function Refresh-FqdnLists {
- $suffixes = Get-FqdnSuffixes
- $cmbCsrDomainSuffix.Items.Clear()
- $cmbInfPrimaryDomain.Items.Clear()
-
- foreach ($suffix in $suffixes) {
- $null = $cmbCsrDomainSuffix.Items.Add($suffix)
- $null = $cmbInfPrimaryDomain.Items.Add($suffix)
- }
-
- if ($suffixes.Count -gt 0) {
- if ([string]::IsNullOrWhiteSpace($cmbCsrDomainSuffix.Text)) { $cmbCsrDomainSuffix.SelectedIndex = 0 }
- if ([string]::IsNullOrWhiteSpace($cmbInfPrimaryDomain.Text)) { $cmbInfPrimaryDomain.SelectedIndex = 0 }
- }
-}
-
-function Set-InfTemplatePreset([string]$preset) {
- switch ($preset) {
- "Default (certmgr)" { $txtInfTemplate.Text = $defaultTemplate }
- "Legacy (MBIE)" { $txtInfTemplate.Text = $legacyTemplate }
- default { }
- }
-}
-
-function Get-NameItemsFromFile([string]$path) {
- if (-not (Test-Path -Path $path -PathType Leaf)) {
- throw "Input file not found: $path"
- }
- return Get-Content -Path $path | ForEach-Object { $_.Trim() } | Where-Object { $_ }
-}
-
-function Get-HostLabel([string]$fqdnOrName) {
- if ($fqdnOrName -match '\.') { return $fqdnOrName.Split('.')[0] }
- return $fqdnOrName
-}
-
-function Resolve-Fqdn([string]$name, [string]$suffix) {
- if ($name -match '\.') { return $name }
- if ([string]::IsNullOrWhiteSpace($suffix)) {
- throw "Missing domain suffix for name: $name"
- }
- return "$name.$suffix"
-}
-
-function Get-SafeFileName([string]$name) {
- $invalidChars = [IO.Path]::GetInvalidFileNameChars()
- return -join ($name.ToCharArray() | ForEach-Object {
- if ($invalidChars -contains $_) { '_' } else { $_ }
- })
-}
-
-function Build-CsrItems([string[]]$names, [string]$suffix, [bool]$useFqdnFileName) {
- $items = @()
- foreach ($name in $names) {
- $fqdn = Resolve-Fqdn $name $suffix
- $hostLabel = Get-HostLabel $fqdn
- $fileBase = if ($useFqdnFileName) { $fqdn } else { $hostLabel }
- $items += [PSCustomObject]@{
- InputName = $name
- HostLabel = $hostLabel
- Fqdn = $fqdn
- FileBase = $fileBase
- }
- }
- return $items
-}
-
-function Get-SanValues([string]$hostLabel, [string]$suffix, [string]$rawText) {
- $values = @()
- if ([string]::IsNullOrWhiteSpace($rawText)) { return $values }
-
- $rawItems = $rawText -split "(\r?\n|,)" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
- foreach ($item in $rawItems) {
- $value = $item.Replace("{host}", $hostLabel)
- if ($value -notmatch '\.' -and -not [string]::IsNullOrWhiteSpace($suffix)) {
- $value = "$value.$suffix"
- }
- $values += $value
- }
-
- return $values | Select-Object -Unique
-}
-
-function Build-SanBlock([string[]]$fqdns) {
- if (-not $fqdns -or $fqdns.Count -eq 0) { return "" }
- $lines = @(
- "[Extensions]",
- "2.5.29.17 = ""{text}"""
- )
- foreach ($fqdn in $fqdns) {
- $lines += "_continue_ = ""dns=$fqdn&"""
- }
- return ($lines -join "`r`n")
-}
-
-$cmbInfTemplatePreset.Items.Clear()
-$cmbInfTemplatePreset.Items.Clear()
-$null = $cmbInfTemplatePreset.Items.Add("Default (certmgr)")
-$null = $cmbInfTemplatePreset.Items.Add("Legacy (MBIE)")
-$null = $cmbInfTemplatePreset.Items.Add("Custom (edit below)")
-if ([string]::IsNullOrWhiteSpace($cmbInfTemplatePreset.Text)) {
- $cmbInfTemplatePreset.SelectedIndex = 0
- Set-InfTemplatePreset "Default (certmgr)"
-}
-
-$cmbInfTemplatePreset.Add_SelectionChanged({
- $selected = $cmbInfTemplatePreset.SelectedItem
- if ($selected) { Set-InfTemplatePreset $selected.ToString() }
})
-Refresh-FqdnLists
-
-if (Test-Path -Path $txtSettingsPath.Text -PathType Leaf) {
- Load-Settings
-}
-
-$btnOpenSettings.Add_Click({
+$csrImportBtn.Add_Click({
try {
- [xml]$settingsDoc = $settingsXaml
- $settingsReader = New-Object System.Xml.XmlNodeReader $settingsDoc
- $settingsWindow = [Windows.Markup.XamlReader]::Load($settingsReader)
+ $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" }
- function Get-SettingsControl([string]$name) {
- return $settingsWindow.FindName($name)
+ $csrFiles = Get-ChildItem -Path $folder -Include *.csr, *.pem -File -Recurse
+ if (-not $csrFiles) {
+ & $logAction "No CSR files found in $folder"
+ return
}
- $txtSettingsPathWin = Get-SettingsControl "txtSettingsPathWin"
- $txtLogPathWin = Get-SettingsControl "txtLogPathWin"
- $btnBrowseSettingsWin = Get-SettingsControl "btnBrowseSettingsWin"
- $btnBrowseLogWin = Get-SettingsControl "btnBrowseLogWin"
- $txtFqdnListWin = Get-SettingsControl "txtFqdnListWin"
- $btnSaveSettingsWin = Get-SettingsControl "btnSaveSettingsWin"
- $btnLoadSettingsWin = Get-SettingsControl "btnLoadSettingsWin"
- $btnCloseSettingsWin = Get-SettingsControl "btnCloseSettingsWin"
-
- $txtSettingsPathWin.Text = $txtSettingsPath.Text
- $txtLogPathWin.Text = $txtLogPath.Text
- $txtFqdnListWin.Text = $script:FqdnListText
-
- $btnBrowseSettingsWin.Add_Click({
- $path = Open-FileDialog "JSON (*.json)|*.json|All files (*.*)|*.*"
- if ($path) { $txtSettingsPathWin.Text = $path }
- })
-
- $btnBrowseLogWin.Add_Click({
- $path = Open-FileDialog "Log (*.log)|*.log|All files (*.*)|*.*"
- if ($path) { $txtLogPathWin.Text = $path }
- })
-
- $btnSaveSettingsWin.Add_Click({
- $txtSettingsPath.Text = $txtSettingsPathWin.Text
- $txtLogPath.Text = $txtLogPathWin.Text
- $script:FqdnListText = $txtFqdnListWin.Text
- Refresh-FqdnLists
- Save-Settings
- })
-
- $btnLoadSettingsWin.Add_Click({
- $txtSettingsPath.Text = $txtSettingsPathWin.Text
- Load-Settings
- $txtSettingsPathWin.Text = $txtSettingsPath.Text
- $txtLogPathWin.Text = $txtLogPath.Text
- $txtFqdnListWin.Text = $script:FqdnListText
- })
-
- $btnCloseSettingsWin.Add_Click({ $settingsWindow.Close() })
-
- $settingsWindow.Owner = $window
- $settingsWindow.ShowDialog() | Out-Null
+ $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 {
- Write-Log "Failed to open settings: $($_.Exception.Message)" "ERROR"
+ & $logAction "Error: $($_.Exception.Message)"
}
})
-$btnBrowseInfInput.Add_Click({
- $path = Open-FileDialog "Text files (*.txt)|*.txt|All files (*.*)|*.*"
- if ($path) { $txtInfInputPath.Text = $path }
-})
-
-$btnBrowseInfOutput.Add_Click({
- $path = Open-FolderDialog
- if ($path) { $txtInfOutputDir.Text = $path }
-})
-
-$btnBrowseDnsListInput.Add_Click({
- $path = Open-FileDialog "Text files (*.txt)|*.txt|All files (*.*)|*.*"
- if ($path) { $txtDnsListInputPath.Text = $path }
-})
-
-$btnBrowseDnsPlaceholderOutput.Add_Click({
- $path = Open-FolderDialog
- if ($path) { $txtDnsPlaceholderOutput.Text = $path }
-})
-
-$btnBrowseWacsPath.Add_Click({
- $path = Open-FileDialog "Executable (*.exe)|*.exe|All files (*.*)|*.*"
- if ($path) { $txtWacsPath.Text = $path }
-})
-
-$btnBrowseWacsPfxFolder.Add_Click({
- $path = Open-FolderDialog
- if ($path) { $txtWacsPfxFolder.Text = $path }
-})
-
-$btnClearLog.Add_Click({
- $txtLogOutput.Clear()
-})
-
-$rbFlowCsr.Add_Click({
- $pnlCsrFlow.Visibility = "Visible"
- $pnlInfFlow.Visibility = "Collapsed"
-})
-
-$rbFlowInf.Add_Click({
- $pnlCsrFlow.Visibility = "Collapsed"
- $pnlInfFlow.Visibility = "Visible"
-})
-
-$btnPreviewCsr.Add_Click({
+$dnsScanBtn.Add_Click({
try {
- $listPath = $txtDnsListInputPath.Text.Trim()
- $suffix = $cmbCsrDomainSuffix.Text.Trim()
- $names = Get-NameItemsFromFile $listPath
- $useFqdnFileName = [bool]$rbCsrFileFqdn.IsChecked
- $items = Build-CsrItems $names $suffix $useFqdnFileName
-
- $previewLines = $items | ForEach-Object {
- "{0} -> {1} -> {2}.csr" -f $_.InputName, $_.Fqdn, $_.FileBase
+ $dnsListBox.Items.Clear()
+ $servers = Get-DnsServerCandidates
+ if (-not $servers -or $servers.Count -eq 0) {
+ & $logAction "No DNS servers found. Enter servers manually."
+ return
}
- $txtCsrPreview.Text = ($previewLines -join "`r`n")
+ 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 {
- Write-Log "Preview failed: $($_.Exception.Message)" "ERROR"
+ & $logAction "Error: $($_.Exception.Message)"
}
})
-$btnRunInf.Add_Click({
+$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({
+ 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
+ }
+})
+
+$clearBtn.Add_Click({
+ $logBox.Clear()
+})
+
+$runBtn.Add_Click({
+ $runBtn.Enabled = $false
try {
- $inputPath = $txtInfInputPath.Text.Trim()
- $outputDir = $txtInfOutputDir.Text.Trim()
- $template = $txtInfTemplate.Text
- $placeholderToken = '$Placeholder'
- $primaryDomain = $cmbInfPrimaryDomain.Text.Trim()
- $additionalDomains = @()
- if (-not [string]::IsNullOrWhiteSpace($txtInfAdditionalDomains.Text)) {
- $additionalDomains = $txtInfAdditionalDomains.Text -split "(\r?\n|,)" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
- }
+ $hosts = @()
+ $hosts += Split-List $hostsBox.Text
- if (-not (Test-Path -Path $inputPath -PathType Leaf)) {
- throw "Input file not found: $inputPath"
- }
- if (-not (Test-Path -Path $outputDir -PathType Container)) {
- throw "Output directory not found: $outputDir"
- }
-
- $lines = Get-Content -Path $inputPath
- $count = 0
-
- foreach ($line in $lines) {
- $name = $line.Trim()
- if ([string]::IsNullOrWhiteSpace($name)) { continue }
-
- $hostLabel = Get-HostLabel $name
- $primaryFqdn = if ($name -match '\.') { $name } else { Resolve-Fqdn $name $primaryDomain }
-
- $content = $template.Replace($placeholderToken, $hostLabel)
- if ([bool]$chkInfAdditionalSans.IsChecked) {
- $sanFqdns = @($primaryFqdn)
- foreach ($domain in $additionalDomains) {
- $sanFqdns += "$hostLabel.$domain"
- }
- $sanFqdns = $sanFqdns | Select-Object -Unique
- if ($content -notmatch '\[Extensions\]') {
- $content = $content.TrimEnd() + "`r`n`r`n" + (Build-SanBlock $sanFqdns)
- }
+ if (-not [string]::IsNullOrWhiteSpace($fileBox.Text)) {
+ if (-not (Test-Path -Path $fileBox.Text -PathType Leaf)) {
+ throw "Hostnames file not found: $($fileBox.Text)"
}
-
- $safeName = Get-SafeFileName $hostLabel
- $outPath = Join-Path -Path $outputDir -ChildPath ($safeName + ".inf")
- Set-Content -Path $outPath -Value $content -Encoding ascii
- $count++
- Write-Log "Generated: $outPath"
+ $fileHosts = Get-Content -Path $fileBox.Text | ForEach-Object { $_.Trim() } | Where-Object { $_ }
+ $hosts += $fileHosts
}
- Write-Log "INF generation completed. Files created: $count"
+ 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 ($selectedReplicationTargets.Count -gt 0) {
+ $replicationTargets = $selectedReplicationTargets
+ } else {
+ $replicationTargets = Split-List $replicationTargetsBox.Text
+ }
+ Invoke-Replication -Servers $replicationTargets -Command $replicationCmdBox.Text -Log $logAction
+
+ if ($runWacsBox.Checked) {
+ $wacsPath = $wacsPathBox.Text.Trim()
+ if (-not (Test-Path -Path $wacsPath -PathType Leaf)) {
+ throw "WACS not found at: $wacsPath"
+ }
+ foreach ($entry in $hostEntries) {
+ Invoke-Wacs `
+ -WacsPath $wacsPath `
+ -HostFqdn $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
+ }
+ }
+
+ & $logAction "Done."
} catch {
- Write-Log "INF generation failed: $($_.Exception.Message)" "ERROR"
+ & $logAction "Error: $($_.Exception.Message)"
+ } finally {
+ $runBtn.Enabled = $true
}
})
-$btnRunWacs.Add_Click({
- try {
- $listPath = $txtDnsListInputPath.Text.Trim()
- $suffix = $cmbCsrDomainSuffix.Text.Trim()
- $wacsPath = $txtWacsPath.Text.Trim()
- $pfxFolder = $txtWacsPfxFolder.Text.Trim()
- $baseUri = $txtWacsBaseUri.Text.Trim()
- $validation = $txtWacsValidation.Text.Trim()
- $validationPort = $txtWacsValidationPort.Text.Trim()
- $target = $txtWacsTarget.Text.Trim()
- $store = $txtWacsStore.Text.Trim()
- $createPlaceholders = [bool]$chkDnsCreatePlaceholders.IsChecked
- $updateDns = [bool]$chkDnsUpdate.IsChecked
- $dnsZone = $txtDnsZone.Text.Trim()
- $targetIp = $txtDnsTargetIp.Text.Trim()
- $dnsServer = $txtDnsServer.Text.Trim()
- $placeholderOutput = $txtDnsPlaceholderOutput.Text.Trim()
- $addSans = [bool]$chkWacsAddSans.IsChecked
- $rawSans = $txtWacsSans.Text
- $verbose = [bool]$chkWacsVerbose.IsChecked
+[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 })
- $names = Get-NameItemsFromFile $listPath
- $useFqdnFileName = [bool]$rbCsrFileFqdn.IsChecked
- $items = Build-CsrItems $names $suffix $useFqdnFileName
-
- if (-not (Test-Path -Path $wacsPath -PathType Leaf)) {
- throw "WACS executable not found: $wacsPath"
- }
- if ([string]::IsNullOrWhiteSpace($pfxFolder)) { throw "PFX output folder is required." }
- if (-not (Test-Path -Path $pfxFolder -PathType Container)) {
- New-Item -Path $pfxFolder -ItemType Directory -Force | Out-Null
- }
-
- if ($createPlaceholders) {
- if ([string]::IsNullOrWhiteSpace($placeholderOutput)) {
- throw "Placeholder output folder is required when creating CSR placeholders."
- }
- if (-not (Test-Path -Path $placeholderOutput -PathType Container)) {
- New-Item -Path $placeholderOutput -ItemType Directory -Force | Out-Null
- }
- foreach ($item in $items) {
- $safeName = Get-SafeFileName $item.FileBase
- $outPath = Join-Path -Path $placeholderOutput -ChildPath ($safeName + ".csr")
- Set-Content -Path $outPath -Value "" -Encoding ascii
- Write-Log "Created placeholder CSR: $outPath"
- }
- }
-
- if ($updateDns) {
- if ([string]::IsNullOrWhiteSpace($dnsZone)) { throw "DNS zone is required." }
- if ([string]::IsNullOrWhiteSpace($targetIp)) { throw "Target IP is required." }
- if ([string]::IsNullOrWhiteSpace($dnsServer)) { throw "DNS server is required." }
-
- foreach ($item in $items) {
- $fqdn = $item.Fqdn
- if ([string]::IsNullOrWhiteSpace($fqdn)) { continue }
-
- if ($fqdn.ToLower().EndsWith(".$($dnsZone.ToLower())")) {
- $hostname = $fqdn.Substring(0, $fqdn.Length - $dnsZone.Length - 1)
- } else {
- $hostname = $fqdn
- }
-
- try {
- Add-DnsServerResourceRecordA `
- -Name $hostname `
- -ZoneName $dnsZone `
- -IPv4Address $targetIp `
- -ComputerName $dnsServer `
- -ErrorAction Stop
- Write-Log "DNS record added: $hostname.$dnsZone -> $targetIp"
- } catch {
- Write-Log "DNS record failed for ${fqdn}: $($_.Exception.Message)" "WARN"
- }
- }
- }
-
- foreach ($item in $items) {
- $hostValue = $item.Fqdn
- if ([string]::IsNullOrWhiteSpace($hostValue)) { continue }
-
- $args = @(
- "--target", $target,
- "--host", $hostValue,
- "--store", $store,
- "--pfxfilepath", $pfxFolder,
- "--baseuri", $baseUri,
- "--validation", $validation,
- "--validationport", $validationPort
- )
-
- if ($addSans) {
- $sanValues = Get-SanValues $item.HostLabel $suffix $rawSans
- foreach ($san in $sanValues) {
- $args += @("--san", $san)
- }
- }
-
- if ($verbose) { $args += "--verbose" }
-
- Write-Log "Running WACS for $hostValue"
- Write-Log "& $wacsPath $($args -join ' ')"
-
- try {
- & $wacsPath @args
- Write-Log "Completed: $hostValue"
- } catch {
- Write-Log "WACS failed for ${hostValue}: $($_.Exception.Message)" "WARN"
- }
- }
-
- Write-Log "WACS processing completed."
- } catch {
- Write-Log "WACS processing failed: $($_.Exception.Message)" "ERROR"
- }
-})
-
-$window.ShowDialog() | Out-Null
+[void]$form.ShowDialog()