1 #!powershell
2 
3 # Copyright: (c) 2017, Red Hat, Inc.
4 # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
5 
6 #AnsibleRequires -CSharpUtil Ansible.Basic
7 
8 Set-StrictMode -Version 2
9 $ConfirmPreference = "None"
10 
11 $spec = @{
12     options = @{
13         database_path = @{ type = 'path' }
14         dns_domain_name = @{ type = 'str' }
15         domain_admin_password = @{ type = 'str'; required = $true; no_log = $true }
16         domain_admin_user = @{ type = 'str'; required = $true }
17         domain_log_path = @{ type = 'path' }  # TODO: Add alias for log_path once log_path has been deprecated.
18         install_dns = @{ type = 'bool' }
19         install_media_path = @{ type = 'path' }
20         local_admin_password = @{ type = 'str'; no_log = $true }
21         log_path = @{
22             type = 'str'
23             removed_at_date = [DateTime]::ParseExact("2022-07-01", "yyyy-MM-dd", $null)
24             removed_from_collection = 'ansible.windows'
25         }
26         read_only = @{ type = 'bool'; default = $false}
27         site_name = @{ type = 'str' }
28         state = @{ type = 'str'; required = $true; choices = 'domain_controller', 'member_server' }
29         sysvol_path = @{ type = 'path' }
30         safe_mode_password = @{ type = 'str'; no_log = $true }
31     }
32     required_if = @(
33         ,@('state', 'domain_controller', @('dns_domain_name', 'safe_mode_password'))
34         ,@('state', 'member_server', @(,'local_admin_password'))
35     )
36     supports_check_mode = $true
37 }
38 $module = [Ansible.Basic.AnsibleModule]::Create($args, $spec)
39 
40 $database_path = $module.Params.database_path
41 $dns_domain_name = $module.Params.dns_domain_name
42 $domain_admin_password = $module.Params.domain_admin_password
43 $domain_admin_user = $module.Params.domain_admin_user
44 $domain_log_path = $module.Params.domain_log_path
45 $install_dns = $module.Params.install_dns
46 $install_media_path = $module.Params.install_media_path
47 $local_admin_password = $module.Params.local_admin_password
48 $read_only = $module.Params.read_only
49 $site_name = $module.Params.site_name
50 $state = $module.Params.state
51 $sysvol_path = $module.Params.sysvol_path
52 $safe_mode_password = $module.Params.safe_mode_password
53 
54 # Used by Write-DebugLog - is deprecated.
55 Set-Variable -Name log_path -Scope Global -Value $module.Params.log_path
56 
57 $module.Result.reboot_required = $false
58 
59 # Set of features required for a domain controller
60 $dc_required_features = @("AD-Domain-Services","RSAT-ADDS")
61 
Write-DebugLognull62 Function Write-DebugLog {
63     Param(
64         [string]$msg
65     )
66 
67     $DebugPreference = "Continue"
68     $ErrorActionPreference = "Continue"
69     $date_str = Get-Date -Format u
70     $msg = "$date_str $msg"
71 
72     Write-Debug $msg
73     $log_path = Get-Variable -Name log_path -Scope Global -ValueOnly -ErrorAction SilentlyContinue
74     if($log_path) {
75         Add-Content -LiteralPath $log_path -Value $msg
76     }
77 }
78 
Get-MissingFeaturesnull79 Function Get-MissingFeatures {
80     Param(
81         [string[]]$required_features
82     )
83     Write-DebugLog "Checking for missing Windows features..."
84 
85     $features = @(Get-WindowsFeature $required_features)
86     # Check for $required_features that are not in $features
87     $unavailable_features = @(Compare-Object -ReferenceObject $required_features -DifferenceObject ($features | Select-Object -ExpandProperty Name) -PassThru)
88 
89     if ($unavailable_features) {
90         Throw "The following features required for a domain controller are unavailable: $($unavailable_features -join ',')"
91     }
92 
93     $missing_features = @($features | Where-Object InstallState -ne Installed)
94 
95     return ,$missing_features # comma needed to force array type output
96 }
97 
Install-FeatureInstallation()98 Function Install-FeatureInstallation {
99     Param(
100         [string[]]$required_features,
101         [Object]$module
102     )
103     # Ensure required features are installed
104 
105     Write-DebugLog "Ensuring required Windows features are installed..."
106     $feature_result = Install-WindowsFeature $required_features
107     $module.Result.reboot_required = $feature_result.RestartNeeded
108 
109     If(-not $feature_result.Success) {
110         $module.FailJson("Error installing AD-Domain-Services, RSAT-ADDS, and RSAT-AD-AdminCenter features: {0}" -f ($feature_result | Out-String))
111     }
112 }
113 
114 # return the domain we're a DC for, or null if not a DC
Get-DomainControllerDomain()115 Function Get-DomainControllerDomain {
116     Write-DebugLog "Checking for domain controller role and domain name"
117 
118     $sys_cim = Get-CIMInstance Win32_ComputerSystem
119 
120     $is_dc = $sys_cim.DomainRole -in (4,5) # backup/primary DC
121     # this will be our workgroup or joined-domain if we're not a DC
122     $domain = $sys_cim.Domain
123 
124     Switch($is_dc) {
125         $true { return $domain }
126         Default { return $null }
127     }
128 }
129 
New-Credentialnull130 Function New-Credential {
131     Param(
132         [string] $cred_user,
133         [string] $cred_password
134     )
135 
136     $cred = New-Object System.Management.Automation.PSCredential($cred_user, $($cred_password | ConvertTo-SecureString -AsPlainText -Force))
137 
138     Return $cred
139 }
140 
Get-OperationMasterRolesnull141 Function Get-OperationMasterRoles {
142     $assigned_roles = @((Get-ADDomainController -Server localhost).OperationMasterRoles)
143 
144     Return ,$assigned_roles # no, the comma's not a typo- allows us to return an empty array
145 }
146 
147 Try {
148     # ensure target OS support; < 2012 doesn't have cmdlet support for DC promotion
149     If(-not (Get-Command Install-WindowsFeature -ErrorAction SilentlyContinue)) {
150         $module.FailJson("win_domain_controller requires at least Windows Server 2012")
151     }
152 
153     # short-circuit "member server" check, since we don't need feature checks for this...
154 
155     $current_dc_domain = Get-DomainControllerDomain
156 
157     If($state -eq "member_server" -and -not $current_dc_domain) {
158         $module.ExitJson()
159     }
160 
161     # all other operations will require the AD-DS and RSAT-ADDS features...
162 
163     $missing_features = Get-MissingFeatures $dc_required_features
164 
165     If($missing_features.Count -gt 0) {
166         Write-DebugLog ("Missing Windows features ({0}), need to install" -f ($missing_features -join ", "))
167         $module.Result.changed = $true # we need to install features
168         If($module.CheckMode) {
169             # bail out here- we can't proceed without knowing the features are installed
170             Write-DebugLog "check-mode, exiting early"
171             $module.ExitJson()
172         }
173 
174         Install-FeatureInstallation $dc_required_features $module | Out-Null
175     }
176 
177     $domain_admin_cred = New-Credential -cred_user $domain_admin_user -cred_password $domain_admin_password
178 
179     switch($state) {
180         domain_controller {
181             # ensure that domain admin user is in UPN or down-level domain format (prevent hang from https://support.microsoft.com/en-us/kb/2737935)
182             If(-not $domain_admin_user.Contains("\") -and -not $domain_admin_user.Contains("@")) {
183                 $module.FailJson("domain_admin_user must be in domain\user or user@domain.com format")
184             }
185 
186             If($current_dc_domain) {
187                 # FUTURE: implement managed Remove/Add to change domains?
188 
189                 If($current_dc_domain -ne $dns_domain_name) {
190                     $module.FailJson("$(hostname) is a domain controller for domain $current_dc_domain; changing DC domains is not implemented")
191                 }
192             }
193 
194             # need to promote to DC
195             If(-not $current_dc_domain) {
196                 Write-DebugLog "Not currently a domain controller; needs promotion"
197                 $module.Result.changed = $true
198                 If($module.CheckMode) {
199                     Write-DebugLog "check-mode, exiting early"
200                     $module.ExitJson()
201                 }
202 
203                 $module.Result.reboot_required = $true
204 
205                 $safe_mode_secure = $safe_mode_password | ConvertTo-SecureString -AsPlainText -Force
206                 Write-DebugLog "Installing domain controller..."
207                 $install_params = @{
208                     DomainName = $dns_domain_name
209                     Credential = $domain_admin_cred
210                     SafeModeAdministratorPassword = $safe_mode_secure
211                 }
212                 if ($database_path) {
213                     $install_params.DatabasePath = $database_path
214                 }
215                 if ($domain_log_path) {
216                     $install_params.LogPath = $domain_log_path
217                 }
218                 if ($sysvol_path) {
219                     $install_params.SysvolPath = $sysvol_path
220                 }
221                 if ($install_media_path) {
222                     $install_params.InstallationMediaPath = $install_media_path
223                 }
224                 if ($read_only) {
225                     # while this is a switch value, if we set on $false site_name is required
226                     # https://github.com/ansible/ansible/issues/35858
227                     $install_params.ReadOnlyReplica = $true
228                 }
229                 if ($site_name) {
230                     $install_params.SiteName = $site_name
231                 }
232                 if ($null -ne $install_dns) {
233                     $install_params.InstallDns = $install_dns
234                 }
235                 try
236                 {
237                     $null = Install-ADDSDomainController -NoRebootOnCompletion -Force @install_params
238                 } catch [Microsoft.DirectoryServices.Deployment.DCPromoExecutionException] {
239                     # ExitCode 15 == 'Role change is in progress or this computer needs to be restarted.'
240                     # DCPromo exit codes details can be found at https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/deploy/troubleshooting-domain-controller-deployment
241                     if ($_.Exception.ExitCode -eq 15) {
242                         $module.Result.reboot_required = $true
243                     } else {
244                         $module.FailJson("Failed to install ADDSDomainController with DCPromo: $($_.Exception.Message)", $_)
245                     }
246                 }
247                 # If $_.FullyQualifiedErrorId -eq 'Test.VerifyUserCredentialPermissions.DCPromo.General.25,Microsoft.DirectoryServices.Deployment.PowerShell.Commands.InstallADDSDomainControllerCommand'
248                 # the module failed to resolve the given dns domain name
249 
250                 Write-DebugLog "Installation complete, trying to start the Netlogon service"
251                 # The Netlogon service is set to auto start but is not started. This is
252                 # required for Ansible to connect back to the host and reboot in a
253                 # later task. Even if this fails Ansible can still connect but only
254                 # with ansible_winrm_transport=basic so we just display a warning if
255                 # this fails.
256                 try {
257                     Start-Service -Name Netlogon
258                 } catch {
259                     Write-DebugLog "Failed to start the Netlogon service: $($_.Exception.Message)"
260                     $module.Warn("Failed to start the Netlogon service after promoting the host, Ansible may be unable to connect until the host is manually rebooting: $($_.Exception.Message)")
261                 }
262 
263                 Write-DebugLog "Domain Controller setup completed, needs reboot..."
264             }
265         }
266         member_server {
267             # at this point we already know we're a DC and shouldn't be...
268             Write-DebugLog "Need to uninstall domain controller..."
269             $module.Result.changed = $true
270 
271             Write-DebugLog "Checking for operation master roles assigned to this DC..."
272 
273             $assigned_roles = Get-OperationMasterRoles
274 
275             # FUTURE: figure out a sane way to hand off roles automatically (designated recipient server, randomly look one up?)
276             If($assigned_roles.Count -gt 0) {
277                 $module.FailJson("This domain controller has operation master role(s) ({0}) assigned; they must be moved to other DCs before demotion (see Move-ADDirectoryServerOperationMasterRole)" -f ($assigned_roles -join ", "))
278             }
279 
280             If($module.CheckMode) {
281                 Write-DebugLog "check-mode, exiting early"
282                 $module.ExitJson()
283             }
284 
285             $module.Result.reboot_required = $true
286 
287             $local_admin_secure = $local_admin_password | ConvertTo-SecureString -AsPlainText -Force
288 
289             Write-DebugLog "Uninstalling domain controller..."
290             Uninstall-ADDSDomainController -NoRebootOnCompletion -LocalAdministratorPassword $local_admin_secure -Credential $domain_admin_cred
291             Write-DebugLog "Uninstallation complete, needs reboot..."
292         }
293         default { throw ("invalid state {0}" -f $state) }
294     }
295 
296     $module.ExitJson()
297 }
298 Catch {
299     $excep = $_
300 
301     Write-DebugLog "Exception: $($excep | out-string)"
302 
303     Throw
304 }
305