From daa1608fdf47eff256bb2dc8d4f2930c693b2e41 Mon Sep 17 00:00:00 2001 From: Jasper Mayone Date: Fri, 13 Nov 2026 00:12:12 -0500 Subject: [PATCH 1/2] feat(ansible): run ad converge build from ad.yml --- ad/README.md | 8 ++++++++ ansible/README.md | 5 ++++- ansible/ad.yml | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/ad/README.md b/ad/README.md index df32b05..0c3ad1d 100644 --- a/ad/README.md +++ b/ad/README.md @@ -64,6 +64,14 @@ Run on a DC, or on a host with RSAT, as `adm_mayonej`. The GPO step needs the Gr .\converge.ps1 # Build ``` +From the control node, Ansible can run Build on loom as `svc-ansible` (`../ansible/README.md`, tag `converge`): + +```sh +cd ansible +ansible-playbook ad.yml -t converge # -WhatIf +ansible-playbook ad.yml -t converge -e ad_converge_apply=true # Build +``` + Logs go to `C:\ProgramData\Patchwork\ad-converge\`. Each run ends with a count of changes, reports and errors. A REPORT line needs a person to look at it. The script changes nothing for it. New accounts are disabled and must change the password at the next logon. The script never sets, reads or logs a password. To start an account, set a first password (or let Weave set it), then enable the account. diff --git a/ansible/README.md b/ansible/README.md index 57cfd84..606a1c5 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -28,7 +28,7 @@ Ansible does not manage these hosts: The Proxmox layer (VM creation, templates, the network gear) is not in this repo. -These jobs are done by hand and are not in Ansible: AD CS setup and the offline root signing, NPS policies, and the GPOs other than the two armoring GPOs and the password values of the `Default Domain Policy` (see "Tier 0"). The OUs, users and groups are in `../ad/` (`converge.ps1`). +These jobs are done by hand and are not in Ansible: AD CS setup and the offline root signing, NPS policies, and the GPOs other than the two armoring GPOs and the password values of the `Default Domain Policy` (see "Tier 0"). The OUs, users and groups are in `../ad/` (`converge.ps1`). `ad.yml -t converge` runs its Build phase. The Migrate and Prune phases are manual. ## Layout @@ -151,6 +151,8 @@ ansible-playbook windows.yml # all Windows hosts (spool gets ne ansible-playbook windows.yml -l shuttle # one host ansible-playbook ad.yml -t audit # report only, changes nothing ansible-playbook ad.yml -t host-groups # login groups for every NixOS host +ansible-playbook ad.yml -t converge # ../ad/converge.ps1 Build, -WhatIf +ansible-playbook ad.yml -t converge -e ad_converge_apply=true ansible-playbook ad.yml -t dhcp,svc-posture ``` @@ -164,6 +166,7 @@ Every tag is opt-in. A run without `-t` does nothing. Directory changes run once | `audit` | Reports join delegation, Protected Users and service-account posture. Changes nothing. Add `-e ad_audit_strict=true` to fail on a finding. | | `join-delegation` | Gives `svc-lnx-join` the rights to join computers in both computer OUs. | | `host-groups` | Creates `lnx--users` and `lnx--admins` for every NixOS host in the flake. | +| `converge` | Copies `../ad/converge.ps1` and `../ad/org.json` to loom and runs phase Build as `svc-ansible`. The run is `-WhatIf` unless you add `-e ad_converge_apply=true`. The report shows the `WOULD`, `CHANGE`, `REPORT`, `ERROR` and `END` lines. Read the `WOULD` lines before you apply. The play fails when the script reports an error. Migrate and Prune stay manual. | | `svc-posture` | Sets AES only and "not delegatable" on `svc-ansible`, `svc-lnx-join` and `svc-weave-ldap`. | | `user-mail` | Sets the `mail` attribute from `ad_user_mail`. Does nothing while the map is empty. | | `ldap-binds` | Creates the read-only LDAP bind accounts in `ad_ldap_bind_accounts` (`group_vars/patchwork.yml`). The list is empty until an app needs one; the comment there shows how to add one. | diff --git a/ansible/ad.yml b/ansible/ad.yml index 9b1a7e6..d60de00 100644 --- a/ansible/ad.yml +++ b/ansible/ad.yml @@ -80,6 +80,10 @@ # -t administrator-random: -e ad_admin_random_force=true sets a new # password even when the last one is younger than the minimum age. ad_admin_random_force: false + # -t converge: -e ad_converge_apply=true makes the changes. Without it, + # the run is -WhatIf. + ad_converge_apply: false + ad_converge_dir: 'C:\ProgramData\Patchwork\ad-converge\src' tasks: # ------------------------------------------------------------------ dhcp @@ -402,6 +406,49 @@ when: ad_is_primary_dc tags: [host-groups, host-dns, never] + # --------------------------------------------------------------- converge + # Runs ../ad/converge.ps1 (phase Build) on loom, the PDC emulator, as + # svc-ansible. A run is -WhatIf unless -e ad_converge_apply=true. Read the + # WOULD lines of a -WhatIf run before you apply. Migrate and Prune stay + # manual (ad/README.md). The script writes its log to + # C:\ProgramData\Patchwork\ad-converge on loom, as a manual run does. + - name: Converge | Script directory exists + ansible.windows.win_file: + path: "{{ ad_converge_dir }}" + state: directory + when: ad_is_primary_dc + tags: [converge, never] + + - name: Converge | converge.ps1 and org.json match the repo + ansible.windows.win_copy: + src: "{{ ad_flake_dir }}/ad/{{ item }}" + dest: "{{ ad_converge_dir }}\\{{ item }}" + loop: [converge.ps1, org.json] + when: ad_is_primary_dc + tags: [converge, never] + + - name: Converge | Run Build (-WhatIf unless ad_converge_apply) + ansible.windows.win_command: + argv: >- + {{ ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-File', ad_converge_dir ~ '\converge.ps1', '-Phase', 'Build', + '-DataFile', ad_converge_dir ~ '\org.json'] + + ([] if ad_converge_apply | bool else ['-WhatIf']) }} + register: ad_converge + changed_when: ad_converge.stdout_lines | select('search', ' CHANGE ') | list | length > 0 + failed_when: ad_converge.rc != 0 + when: ad_is_primary_dc + tags: [converge, never] + + - name: Converge | Report + ansible.builtin.debug: + msg: >- + {{ ad_converge.stdout_lines + | select('search', ' (WOULD|CHANGE|REPORT|ERROR|END) ') + | list }} + when: ad_is_primary_dc + tags: [converge, never] + # ------------------------------------------------------------ svc-posture # Enforce what -t audit checks on the service accounts: AES-only Kerberos # keys (msDS-SupportedEncryptionTypes 24) and AccountNotDelegated. Neither From adffbded0392a32880c6b6ad58582e4c6e438ba4 Mon Sep 17 00:00:00 2001 From: Jasper Mayone Date: Fri, 13 Nov 2026 00:16:50 -0500 Subject: [PATCH 2/2] fix(ansible): lock down the converge directory and refuse dirty applies --- ad/TIERING.md | 2 +- ansible/README.md | 2 +- ansible/ad.yml | 91 +++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 78 insertions(+), 17 deletions(-) diff --git a/ad/TIERING.md b/ad/TIERING.md index a817446..e9980b8 100644 --- a/ad/TIERING.md +++ b/ad/TIERING.md @@ -289,7 +289,7 @@ Compensating controls for step 5a: - The WinRM firewall rule allows only Jasper's VPN address (`Enable-AnsibleWinRM.ps1 -RemoteAddress`). Today the scope is the control node subnet. Make it one address. This needs the thimble NAT removed first: with NAT, every VPN client arrives as 10.100.92.20. - A GPO denies `svc-ansible` local logon, RDP logon, batch and service logon on every host. - Keep AES only and "not delegatable" (`-t svc-posture`, already live). -- Alert on any logon of `svc-ansible` (4624, 4776) from an address other than the VPN address (one address per person only after the NAT is removed). Alert on any group change that it makes outside an Ansible run. +- Alert on any logon of `svc-ansible` (4624, 4776) from an address other than the VPN address (one address per person only after the NAT is removed). Alert on any group change that it makes outside an Ansible run. `ad.yml -t converge` runs `converge.ps1` Build as `svc-ansible`, so a Build run is an Ansible run too. After the switch to Kerberos (5b), run `ad.yml -t converge` (`-WhatIf`) again and check that it still works. - Rotate the password on a schedule and after any remus incident. ## 3. PAM and just-in-time admin diff --git a/ansible/README.md b/ansible/README.md index 606a1c5..85827c9 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -166,7 +166,7 @@ Every tag is opt-in. A run without `-t` does nothing. Directory changes run once | `audit` | Reports join delegation, Protected Users and service-account posture. Changes nothing. Add `-e ad_audit_strict=true` to fail on a finding. | | `join-delegation` | Gives `svc-lnx-join` the rights to join computers in both computer OUs. | | `host-groups` | Creates `lnx--users` and `lnx--admins` for every NixOS host in the flake. | -| `converge` | Copies `../ad/converge.ps1` and `../ad/org.json` to loom and runs phase Build as `svc-ansible`. The run is `-WhatIf` unless you add `-e ad_converge_apply=true`. The report shows the `WOULD`, `CHANGE`, `REPORT`, `ERROR` and `END` lines. Read the `WOULD` lines before you apply. The play fails when the script reports an error. Migrate and Prune stay manual. | +| `converge` | Copies `../ad/converge.ps1` and `../ad/org.json` to loom and runs phase Build as `svc-ansible`, against loom. The run is `-WhatIf` unless you add `-e ad_converge_apply=true`. Read the `WOULD` lines before you apply. An apply refuses uncommitted changes under `../ad`. Only SYSTEM and Administrators can write to the script directory on loom. The report shows the commit, the `WOULD`, `CHANGE`, `REPORT`, `ERROR` and `END` lines, and stderr. Then the play fails if the script exited with an error. `--check` is not supported. Migrate and Prune stay manual. | | `svc-posture` | Sets AES only and "not delegatable" on `svc-ansible`, `svc-lnx-join` and `svc-weave-ldap`. | | `user-mail` | Sets the `mail` attribute from `ad_user_mail`. Does nothing while the map is empty. | | `ldap-binds` | Creates the read-only LDAP bind accounts in `ad_ldap_bind_accounts` (`group_vars/patchwork.yml`). The list is empty until an app needs one; the comment there shows how to add one. | diff --git a/ansible/ad.yml b/ansible/ad.yml index d60de00..ff4a7fd 100644 --- a/ansible/ad.yml +++ b/ansible/ad.yml @@ -25,6 +25,8 @@ # ad_ca_subject_match against them. REPORT ONLY # -t ca-chain export the forest CA chain to files/pki/ # -t dns-log Microsoft-Windows-DNSServer/Analytical enabled +# -t converge ../ad/converge.ps1 phase Build on loom. -WhatIf +# unless -e ad_converge_apply=true # # Tier 0 (ad/TIERING.md). Run them in the order in ansible/README.md. # -t authn-log Microsoft-Windows-Authentication/* logs and @@ -407,15 +409,59 @@ tags: [host-groups, host-dns, never] # --------------------------------------------------------------- converge - # Runs ../ad/converge.ps1 (phase Build) on loom, the PDC emulator, as - # svc-ansible. A run is -WhatIf unless -e ad_converge_apply=true. Read the - # WOULD lines of a -WhatIf run before you apply. Migrate and Prune stay - # manual (ad/README.md). The script writes its log to + # Runs ../ad/converge.ps1 (phase Build) on loom (ad_dcs[0]) as + # svc-ansible, against loom itself (-Server). A run is -WhatIf unless + # -e ad_converge_apply=true. Read the WOULD lines of a -WhatIf run before + # you apply. An apply refuses uncommitted changes under ../ad. Migrate and + # Prune stay manual (ad/README.md). The script writes its log to # C:\ProgramData\Patchwork\ad-converge on loom, as a manual run does. - - name: Converge | Script directory exists - ansible.windows.win_file: - path: "{{ ad_converge_dir }}" - state: directory + # --check is not supported: win_command cannot run in check mode. + - name: Converge | An apply uses only committed files under ../ad + ansible.builtin.command: + argv: [git, -C, "{{ ad_flake_dir }}", status, --porcelain, --, ad] + delegate_to: localhost + register: ad_converge_git + changed_when: false + failed_when: >- + ad_converge_git.rc != 0 + or (ad_converge_apply | bool and ad_converge_git.stdout | length > 0) + when: ad_is_primary_dc + tags: [converge, never] + + - name: Converge | The commit that this run uses + ansible.builtin.command: + argv: [git, -C, "{{ ad_flake_dir }}", rev-parse, HEAD] + delegate_to: localhost + register: ad_converge_rev + changed_when: false + when: ad_is_primary_dc + tags: [converge, never] + + # Only SYSTEM and Administrators can write to the script directory. The + # inherited ProgramData ACL lets Users create files there, and the + # script runs as a Domain Admin. The task reports a change on every run, + # because it deletes the copies of the last run. + - name: Converge | Script directory exists, writable by admins only + ansible.windows.win_powershell: + parameters: + path: "{{ ad_converge_dir }}" + script: | + param([string]$path) + $Ansible.Changed = $false + New-Item -ItemType Directory -Force -Path $path | Out-Null + $want = 'O:BAG:SYD:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)' + $acl = Get-Acl -Path $path + if ($acl.Sddl -ne $want) { + $acl.SetSecurityDescriptorSddlForm($want) + Set-Acl -Path $path -AclObject $acl + $Ansible.Changed = $true + } + # A file can keep its own owner and ACEs. Delete the old copies, so + # the copy task writes new files that inherit only the ACL above. + Get-ChildItem -Path $path -Force | ForEach-Object { + Remove-Item -LiteralPath $_.FullName -Recurse -Force + $Ansible.Changed = $true + } when: ad_is_primary_dc tags: [converge, never] @@ -432,21 +478,36 @@ argv: >- {{ ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', ad_converge_dir ~ '\converge.ps1', '-Phase', 'Build', - '-DataFile', ad_converge_dir ~ '\org.json'] + '-DataFile', ad_converge_dir ~ '\org.json', + '-Server', ad_dcs[0].name] + ([] if ad_converge_apply | bool else ['-WhatIf']) }} register: ad_converge changed_when: ad_converge.stdout_lines | select('search', ' CHANGE ') | list | length > 0 - failed_when: ad_converge.rc != 0 + # The Report task shows the lines first. The last task fails the play. + failed_when: false when: ad_is_primary_dc tags: [converge, never] - name: Converge | Report ansible.builtin.debug: - msg: >- - {{ ad_converge.stdout_lines - | select('search', ' (WOULD|CHANGE|REPORT|ERROR|END) ') - | list }} - when: ad_is_primary_dc + msg: + commit: "{{ ad_converge_rev.stdout }}" + uncommitted_under_ad: "{{ ad_converge_git.stdout_lines }}" + apply: "{{ ad_converge_apply | bool }}" + lines: >- + {{ ad_converge.stdout_lines + | select('search', ' (WOULD|CHANGE|REPORT|ERROR|END) ') + | list }} + stderr: "{{ ad_converge.stderr_lines }}" + when: ad_is_primary_dc and ad_converge.rc is defined + tags: [converge, never] + + - name: Converge | The script finished with no error + ansible.builtin.assert: + that: ad_converge.rc == 0 + fail_msg: "converge.ps1 exited {{ ad_converge.rc }}. Read the ERROR lines and stderr above." + quiet: true + when: ad_is_primary_dc and ad_converge.rc is defined tags: [converge, never] # ------------------------------------------------------------ svc-posture