Build an Active Directory lab on Azure
You want a domain to test against. Building one by hand means four RDP sessions, three wizards, a DNS change on every server and six reboots, and next month you’ll do it all again. In this tutorial one configuration builds the whole lab on Azure: dc1 makes a new forest, dc2 becomes its second domain controller, srv1 and srv2 join as member servers, and the directory gets OUs, users and groups. You’ll see every step in the plan before anything changes, reboots included.
Time: about 35 minutes, most of it waiting for Windows.
Prerequisites
Section titled “Prerequisites”-
Conn3x v0.7.3 or later installed. It’s the first release with the OU, group and user modules the lab needs.
-
An Azure subscription and the
azCLI, signed in withaz login.Contributoron the subscription is enough. -
Room for 8 vCPUs of the
Bsv2family inwestus2. A new pay-as-you-go subscription can start lower. Check yours:Terminal window az vm list-usage -l westus2 -o table | grep -E 'Total Regional|BSv2' -
An SSH key at
~/.ssh/id_ed25519. Conn3x logs in to every VM with it. -
gitandjq.
Get the example
Section titled “Get the example”The lab lives in the Conn3x repository:
git clone https://github.com/conn3xorg/conn3x.gitcd conn3x/examples/azure-ad-labmain.tf makes the network and the four VMs, then hands them to circuit-active-directory by role:
module "ad" { source = "github.com/conn3xorg/circuit-active-directory?ref=v0.1.1"
depends_on = [azurerm_virtual_machine_extension.bootstrap]
domain_name = local.domain netbios_name = "LAB" domain_controllers = { for k in local.dcs : k => local.hosts[k] } members = { for k, h in local.hosts : k => h if !contains(local.dcs, k) }
ssh_user = "azadmin" ssh_private_key_file = var.admin_private_key_file safe_mode_password = var.dsrm_password
domain_admin_password = random_password.first_admin["dc1"].result
ous = { Lab = { description = "Made by Conn3x" } Servers = { path = "OU=Lab", description = "Member servers" } People = { path = "OU=Lab" } } member_ou = "OU=Servers,OU=Lab"
users = { alice = { path = "OU=People,OU=Lab", given_name = "Alice", surname = "Example", display_name = "Alice Example" } bob = { path = "OU=People,OU=Lab", given_name = "Bob", surname = "Example", display_name = "Bob Example" } } user_passwords = var.user_passwords
groups = { LabAdmins = { path = "OU=Lab", description = "Can manage the lab", members = ["alice"] } Helpdesk = { path = "OU=Lab", description = "Resets passwords", members = ["alice", "bob"] } }}Three things in there are worth knowing before you run it:
- Each host is a host object with its public
address, its pinnedhost_key, and aprivate_ip. The circuit points every server’s DNS at the domain controllers’ private addresses, which is why the VMs get fixed ones. - The first password does one job. Azure makes each VM with a password, and promotion turns
dc1’s into the domain admin’s.dc2and the members join with it, and the last steps ofmain.tfreplace it, and each member’s local one, with passwords nobody knows. Key login keeps working. - Your passwords stay out of state.
dsrm_passwordanduser_passwordsare ephemeral variables, so they never reach the plan or the state file.
Set your inputs
Section titled “Set your inputs”export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"export TF_VAR_state_passphrase="$(openssl rand -hex 16)" # keep this; state needs itexport TF_VAR_dsrm_password="a long password of your own"export TF_VAR_user_passwords='{alice="one for alice", bob="one for bob"}'export TF_VAR_admin_cidrs="[\"$(curl -4 -s https://ifconfig.me)/32\"]"export TF_VAR_admin_public_key_file=~/.ssh/id_ed25519.pubexport TF_VAR_admin_private_key_file=~/.ssh/id_ed25519The state holds secrets of Azure’s making, such as each VM’s host private key, so the example encrypts it with state_passphrase. Lose the passphrase and the state is gone with it. SSH is open to admin_cidrs only, and RDP stays closed.
conn3x initconn3x plan -out tfplanThe end of the plan lists every step on every host, 59 resources in all:
# module.ad.conn3x_windows_ad_domain_controller.domain_controllers__dc["dc2"] will be created # module.ad.conn3x_windows_ad_forest.forest__forest["dc1"] will be created # module.ad.conn3x_windows_ad_group.forest__groups["dc1/Helpdesk"] will be created # module.ad.conn3x_windows_ad_group.forest__groups["dc1/LabAdmins"] will be created # module.ad.conn3x_windows_ad_ou.forest__child_ous["dc1/People"] will be created # module.ad.conn3x_windows_ad_ou.forest__child_ous["dc1/Servers"] will be created # module.ad.conn3x_windows_ad_ou.forest__ous["dc1/Lab"] will be created # module.ad.conn3x_windows_ad_user.forest__users["dc1/alice"] will be created # module.ad.conn3x_windows_ad_user.forest__users["dc1/bob"] will be created # module.ad.conn3x_windows_domain_membership.domain_controllers__join["dc2"] will be created # module.ad.conn3x_windows_domain_membership.members__join["srv1"] will be created # module.ad.conn3x_windows_domain_membership.members__join["srv2"] will be created # module.ad.conn3x_windows_reboot.domain_controllers__joined["dc2"] will be created # module.ad.conn3x_windows_reboot.domain_controllers__promoted["dc2"] will be created # module.ad.conn3x_windows_reboot.forest__promoted["dc1"] will be created # module.ad.conn3x_windows_reboot.members__joined["srv1"] will be created # module.ad.conn3x_windows_reboot.members__joined["srv2"] will be created...Plan: 59 to add, 0 to change, 0 to destroy.That’s shortened: the DNS, feature and wait steps are in there too.
The steps read in the order they’ll run. On dc1: AD DS, the forest, its reboot, a wait for Active Directory Web Services on port 9389, a wait for Get-ADDomain, then DNS, the OUs, the users and the groups. On dc2: AD DS, DNS, the join, a reboot, promotion, another reboot and the same two waits. On each member: DNS, the join and a reboot. Nothing’s been touched yet.
conn3x apply tfplanIt took 15 and a half minutes when we ran it. The VMs and their bootstrap take the first five, then the domain comes together:
module.ad.conn3x_windows_feature.forest__ad_ds["dc1"]: Creation complete after 3m12smodule.ad.conn3x_windows_ad_forest.forest__forest["dc1"]: Creation complete after 1m0smodule.ad.conn3x_windows_reboot.forest__promoted["dc1"]: Creation complete after 1m12smodule.ad.conn3x_windows_powershell.forest__ad_answers["dc1"]: Creation complete after 24smodule.ad.conn3x_windows_ad_ou.forest__child_ous["dc1/Servers"]: Creation complete after 3smodule.ad.conn3x_windows_ad_user.forest__users["dc1/alice"]: Creation complete after 4smodule.ad.conn3x_windows_domain_membership.domain_controllers__join["dc2"]: Creation complete after 3smodule.ad.conn3x_windows_ad_group.forest__groups["dc1/Helpdesk"]: Creation complete after 4smodule.ad.conn3x_windows_domain_membership.members__join["srv1"]: Creation complete after 3smodule.ad.conn3x_windows_reboot.members__joined["srv1"]: Creation complete after 31smodule.ad.conn3x_windows_reboot.domain_controllers__joined["dc2"]: Creation complete after 36smodule.ad.conn3x_windows_ad_domain_controller.domain_controllers__dc["dc2"]: Creation complete after 45smodule.ad.conn3x_windows_reboot.domain_controllers__promoted["dc2"]: Creation complete after 1m14smodule.ad.conn3x_windows_powershell.domain_controllers__ad_answers["dc2"]: Creation complete after 25sconn3x_windows_powershell.dc_password__forget_first_password["dc1"]: Creation complete after 4sApply complete! Resources: 59 added, 0 changed, 0 destroyed.dc2 installs AD DS while dc1 is still making the forest, and the members join while dc1 adds the users and groups, so the four VMs finish in about the time dc2 takes on its own.
Plan again
Section titled “Plan again”conn3x planNo changes. Your infrastructure matches the configuration.Conn3x read every step back from every VM, domain controllers included, and found nothing to do.
Check the domain
Section titled “Check the domain”Log in to dc1 and ask AD about replication. azadmin on a domain controller is now the domain’s LAB\azadmin, with the same key:
ssh azadmin@$(conn3x output -json ips | jq -r .dc1) repadmin /replsummarySource DSA largest delta fails/total %% error dc2 01m:56s 0 / 5 0Destination DSA largest delta fails/total %% error dc1 01m:56s 0 / 5 0Experienced the following operational errors trying to retrieve replication information: 110 - dc2.lab.conn3x.testFive naming contexts from dc2, 0 failures. Run it on dc2 and you see the same from dc1. Two things might surprise you. Right after promotion, dc1 can take a few minutes to list dc2 at all, until Windows’ consistency checker builds the link; repadmin /kcc hurries it along. And the 110 line is repadmin failing to ask the other DC for its side. An SSH key login has no password to hand on to another server, so that’s expected here and not a replication fault. Each DC’s own view is the one that counts.
Now a member server’s trust with the domain:
ssh azadmin@$(conn3x output -json ips | jq -r .srv1) powershell Test-ComputerSecureChannelTrueAnd the directory, from dc1:
ssh azadmin@$(conn3x output -json ips | jq -r .dc1) powershell \ "Get-ADUser -Filter * -SearchBase 'OU=Lab,DC=lab,DC=conn3x,DC=test' | Select-Object -ExpandProperty DistinguishedName; Get-ADGroupMember Helpdesk | Select-Object -ExpandProperty SamAccountName; Get-ADComputer -Filter * | Select-Object -ExpandProperty DistinguishedName"CN=bob,OU=People,OU=Lab,DC=lab,DC=conn3x,DC=testCN=alice,OU=People,OU=Lab,DC=lab,DC=conn3x,DC=testalicebobCN=dc1,OU=Domain Controllers,DC=lab,DC=conn3x,DC=testCN=dc2,OU=Domain Controllers,DC=lab,DC=conn3x,DC=testCN=srv2,OU=Servers,OU=Lab,DC=lab,DC=conn3x,DC=testCN=srv1,OU=Servers,OU=Lab,DC=lab,DC=conn3x,DC=testThe users are in People, the member servers’ computer accounts are in Servers, and Helpdesk has both users.
Try drift
Section titled “Try drift”Take bob out of Helpdesk by hand:
ssh azadmin@$(conn3x output -json ips | jq -r .dc1) powershell "Remove-ADGroupMember Helpdesk bob -Confirm:\$false"conn3x driftconn3x drift exits 2 and names the group:
Drift found in 1 resource.
Hosts ~ module.ad.conn3x_windows_ad_group.forest__groups["dc1/Helpdesk"] changed
Run conn3x plan to see what Conn3x would change to put them back.Below that it lists some Azure resources whose read-only fields moved, which your config wouldn’t change back and which aren’t drift. Run conn3x apply, and it changes one resource and puts bob back. The next conn3x plan is empty again.
Tear it down
Section titled “Tear it down”Delete the resource group. That’s the whole lab, and nothing in it is worth keeping:
az group delete -n conn3x-ad-lab --yesaz group list -o tableName Location Status---------------- ---------- ---------NetworkWatcherRG westus2 SucceededNetworkWatcherRG is Azure’s own, made the first time you build a network in a region, and it costs nothing. conn3x destroy works too, but it asks each Conn3x step to forget its host first, which is slower and gets you nothing here.
Then remove the state, which still holds the hosts’ private keys:
rm -f terraform.tfstate terraform.tfstate.backup tfplanWhat to know
Section titled “What to know”- The second plan is empty right after apply. Hours later it may not be. Windows updates Defender on its own, and when that leaves a reboot pending, the next plan shows a DC’s reboot step again. That’s
only_if_pendingdoing its job. - Removing a step doesn’t undo it. Conn3x never demotes a domain controller or takes a server out of a domain. Delete the VMs for that.
- It’s a lab. AD’s database stays on the OS disk, and both DCs share one DSRM password. For a domain you’d keep, see the circuit’s page for what it doesn’t do yet.
Next steps
Section titled “Next steps”- circuit-active-directory lists every input, and what CI and Azure each test.
- windows_ad_forest and windows_ad_domain_controller explain promotion, logins afterwards and the passwords.
- Provision and configure on Azure is the same pattern with Linux VMs.