This directory configures the lab domain's six guests from phoenix
(ADR-0077). The why is in build-the-lab-domain.md and
ADR-0029. This page covers what is here and how to run it.
set -a; . ~/.config/proxmox/phoenix.env; set +a
scripts/lab-known-hosts.sh # after any of the six is rebuilt
cd ansible
ansible-playbook lab-domain.yml --check --diff # what would change (existing domain only)
ansible-playbook lab-domain.yml # apply
ansible-playbook verify.yml # read-only proofWhat --check reports instead of doing. On a guest this playbook has
never touched, base would create C:\ProgramData\lab, but check mode does
not. Downloading into it would fail, and the serial play would stop there for
every guest after it. So exporter decides "installed" from the pinned MSI's
product code, and licence_clock looks for the folder. Where the work is
still to do, each one reports it as a pending change and skips it in check
mode. A win_powershell script runs in check mode only if
it declares [CmdletBinding(SupportsShouldProcess)]. Every script here that
writes either does, and guards the write with $Ansible.CheckMode, or is
skipped in check mode by when: not ansible_check_mode beside a read-only
probe, as base's static address is. A new one that does neither reports a
change on every --check instead of looking.
Run it from this directory, because ansible.cfg is read from the current
directory. Installing Ansible and the collections on phoenix, the two
secrets, and the one-time step that lets the hand-built six be reached are
covered in the runbook's Run it from phoenix section.
inventory/hosts.yaml: the six guests, at their final addresses.- Groups:
dc_primary,dc_replica,dcs,members,endpoints, andservers(the four evaluation-licensed guests). lab_rolematches therolelabel in the lab Prometheus.
- Groups:
inventory/group_vars/all.yaml: ADR-0029's values, plus how a guest is reached:- SSH as Administrator, with
phoenix's key and PowerShell as the shell. - The pinned
windows_exporterversion and its sha256. - The pinned Sysmon version, the zip's sha256, and the vendored config's
sha256, read from
roles/sysmon/files/VENDORED. - How a population password is derived from
LAB_POPULATION_SEED.
- SSH as Administrator, with
population/: the domain's people (#449).population.yamlis generated byscripts/gen_population.pyfrom the two name lists beside it, and committed. It names each account's department, title and groups, and holds no passwords. Do not edit it by hand: the script's--self-test, which CI runs, fails if the file is not what its header regenerates. To change the population, change the arguments and regenerate.
lab-domain.yml: the playbook, one play per stage, in the runbook's order.verify.yml: read-only. It runs the runbook's §9 checks, and asserts that each item on the "Do not harden this domain" list is still shipped. It also checks thatOU=Peopleholds exactly the population, and that the endpoints' generator ran asauthgenand succeeded, and that Sysmon runs the pinned version with the vendored config.pktmon-start.ymlandpktmon-stop.yml: a packet capture on one guest, brought back tophoenix. See Capture on demand.population-credentials.yml: prints the population's passwords onphoenix, derived again rather than stored. It touches no guest.roles/, in order:base: name, DC static addresses, resolvers, and the build password rotated.dc_forest: forest, forwarder, no root hints, and the clock. It asserts the clock is right before anything joins.dc_replica: the second DC, left on NT5DS.dc_resolvers: each DC resolves through its partner, then loopback.member: joins.exporter:windows_exporter, plus the 9182 rule admittingalexander.licence_clock: the evaluation gauge, on the four servers.population: the OUs, groups and users inpopulation/population.yaml, onbahamut. It reports accounts inOU=Peoplethat the file does not name, and removes none.tiers: the five tier OUs, the three tier admins, theTier 0 Adminsgroup, and the members placed inServers/Workstations(#448).gpos: the GPOs, ensured onbahamutcompare-first, so a second run writes nothing.- Deny Tier 0 Logon on Members, as #414 built it by hand.
- Lab - Audit File Share: the member servers' audit policy, the Windows default set plus File Share (#1077).
- Lab - Audit Domain Controllers: the DCs' 17 subcategories plus Directory Service Changes (#1077).
ds_audit: inherited audit entries on the domain head, so a write to an SPN or a delegation attribute on a user or computer logs a 5136 (#1077).shares:titan'sPublicandFinanceshares, with the decoy (#448).soc_agents: Wazuh and Velociraptor installed directly, by product code, in place of the deploy GPOs (#448).authgen: the authentication generator's batch right and scheduled task, on the two endpoints.weakness_kerberoast,weakness_asreproast,weakness_dcsync,weakness_ucd,weakness_cd,weakness_rbcd: #449's deliberate weaknesses, onbahamut, each behind its own tag and off by default (ADR-0083). Their fixed identifiers are ingroup_vars/all.yaml.sysmon: Sysmon at the pinned version, running sysmon-modular's balanced config, with its channel sized to 256 MiB (#450, ADR-0080). The config infiles/is vendored byte for byte byscripts/vendor-sysmon-config.sh. Do not edit it: re-vendor.dns_forwarder: shared by both DC roles.dns_client: a guest's IPv4 resolvers, set throughnetsh interface ipv4so the IPv6 ones (::1on a DC) are never touched. Used bybase,dc_replicaanddc_resolvers.domain_role: whether a guest is a DC already, imported bybaseanddc_replicain their own plays.
requirements.txt,requirements-lint.txtandrequirements.yml: exact pins foransible-core,ansible-lintand the three collections.
| Tag | Stage | Runbook |
|---|---|---|
base |
Name, address, resolver, password, and the build's WinRM certificate removed | §2 |
packer_cert |
Only that certificate, from a guest cloned from a template built before #1031 | §2 |
forest |
bahamut: forest, forwarder, clock |
§3 |
replica |
leviathan, then both DCs' resolvers |
§4 |
join |
The four members join | §5, first sentence |
exporter |
windows_exporter and its firewall rule |
§7 |
licence |
The evaluation gauge | §7 |
population |
The people: OUs, groups, users | §5 |
authgen |
The generator on the endpoints, as authgen |
§6 |
tiers |
The tier OUs, admins, Tier 0 Admins, member placement |
§5 |
gpos |
Deny Tier 0 logon, and the audit policies of servers and DCs | §5 |
audit |
The domain head's audit entries for attribute writes (ds_audit) |
§5 |
shares |
titan's Public and Finance, with the decoy |
§5 |
soc |
Wazuh and Velociraptor, in place of the deploy GPOs | §11 |
kerberoast |
An SPN on a crackable account, ramuh's target |
§5a |
asreproast |
Pre-auth disabled on a crackable account | §5a |
dcsync |
Replication rights on a non-DA principal | §5a |
ucd |
Unconstrained delegation on ramuh |
§5a |
cd |
Constrained delegation, svc-web to titan CIFS |
§5a |
rbcd |
Resource-based constrained delegation on titan |
§5a |
sysmon |
Sysmon and its config, on every guest | §7 |
The tiers, shares and soc tags (#448) apply the rest of §5's skeleton and
§11's agents. The last six are
#449's deliberate weaknesses
(ADR-0083), each off until asked for: weakness_<name>_state is absent by
default, so a plain run leaves none of them present. Enable one, watch the SOC
see it, disable it, confirm the signal goes:
ansible-playbook lab-domain.yml --tags kerberoast -e weakness_kerberoast_state=present
ansible-playbook verify.yml -e weakness_kerberoast_state=present # it is on
ansible-playbook lab-domain.yml --tags kerberoast # off again
ansible-playbook verify.yml # all six absentA plain ansible-playbook verify.yml asserts every one of these six weakness
primitives is absent — the per-primitive negative test. It does not walk the
domain's authorization graph, so it does not by itself prove no path to
Domain Admin exists; that graph-level check is a BloodHound collector run with
the tags off (#451) — the "run
the collector" half that #449 asks for.
Pktmon is Windows' own packet capture, on all six guests already, with nothing to install. Start a capture on one guest, reproduce the thing, then stop it:
ansible-playbook pktmon-start.yml --limit titan -e pktmon_port=445
ansible-playbook pktmon-stop.yml --limit titanThe stop converts the capture to pcapng, fetches it to
~/captures/<guest>-<UTC time>.pcapng on phoenix, and deletes it from the
guest.
- Both playbooks refuse to run on anything but one guest (
--limit). - Filters:
pktmon_portandpktmon_ipare optional. Given together, a packet must match both. - The capture includes your own SSH session. Pktmon's filters only
include, so an unfiltered capture has
phoenix's session on 22 in it. - Size: it captures at the NIC, whole packets, circular at 512 MB
(
-e pktmon_file_mb=...). - A reboot ends a capture.
pktmon-stop.ymlstill fetches what was written before it. So does a rerun after a stop that failed partway.pktmon-start.ymlrefuses while an unfetched capture is waiting.
- Nothing here hardens anything. No role touches LLMNR, NetBIOS over
TCP/IP, IPv6, WPAD or the DNS
GlobalQueryBlockList, andverify.ymlfails if any of them has changed. Do not add a hardening role, from a collection or by reflex. It deletes the exercises the segment exists for. Auditing is not hardening:gposandds_auditmake the DCs and servers record more, and change nothing an exercise can reach (#1077). - The lab domain, and only the lab domain. No host outside
ad.matrix.elysiumgoes in the inventory. Compose stacks converge on their own timer (ADR-0021), andphoenixnever pushes to them (ADR-0043). - Secrets come from
phoenix.env. The variables areLAB_ADMIN_PASSWORD,LAB_DSRM_PASSWORD,LAB_POPULATION_SEED,LAB_TIER_ADMIN_PASSWORD,LAB_WAZUH_REGISTRATION_PASSWORDandLAB_WEAK_PASSWORD(the deliberately crackable one the weakness accounts share, ADR-0083). Every task that uses one isno_log. The exception ispopulation-credentials.yml, which exists to print passwords and is run by hand. - A second run changes nothing. Every task compares before it acts. A
task that reports
changedon a guest already in the right state is a bug in that task. - Host keys are checked, against
.known_hostsonly.scripts/lab-known-hosts.shwrites it from keys read inside each guest through the Proxmox guest agent, never from what the network presents (ADR-0085). A rebuilt guest is refused until it has run again. Do not turn the check off to get past that; run the script. - CI proves it parses,
phoenixproves it configures.scripts/lint.shrunsansible-lint, which includes the syntax check, under theproductionprofile that.ansible-lintpins. CI has no route to VLAN 30.