feat(ansible): provision ipa.cloonar.com (AlmaLinux 10 + FreeIPA) from nb, with fatal DNS/rDNS preflight #313
Labels
No labels
bug
enhancement
in-progress
needs-info
needs-triage
p0
ready-for-agent
ready-for-human
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/nixos#313
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Build the Ansible tree that stands up a FreeIPA server on a netcup VPS from one command on
nb, and the preflight that refuses to run when DNS isn't right.The design below is settled. Where a decision looks arbitrary it usually isn't — the reasoning is given inline so it doesn't get "improved" back into the thing it replaced.
Context
FreeIPA is installed directly on the VPS, on AlmaLinux 10. Not on NixOS (nixpkgs has the client only —
security.ipais an enrolment module, the server is--disable-server), and not in a container (the image couples OS security updates to FreeIPA version bumps, and a NixOS host underneath would need ~8 GB because Nix evaluation spikes ~1 GB every five minutes on top of FreeIPA's 3–4 GB).The target box is already bought: netcup, 4 GB, x86_64.
No custom image. An earlier plan built a qcow2 from this repo, because netcup provides no cloud-init datasource for custom uploads. That whole workstream is unnecessary: netcup ships AlmaLinux 10 as a stock image, and stock images are exactly the ones where SCP's hostname and SSH-key injection works. Install from the panel, then run the playbook.
Distribution is AlmaLinux 10, not Rocky. Upstream FreeIPA's
freeipa-containerrepo carriesDockerfile.almalinux-{8,9,10},Dockerfile.rocky-{8,9}— there is norocky-10. Alma 10 is in upstream's test matrix; Rocky 10 is not. Rocky is also where the one IdM upgrade breakage we know of landed (9.5→9.6 brokeipa-server-upgrade, RHBZ #2350322). Pin 10.1 or newer: RHEL 10.0 shipped IdM only partially, withipa-server-dnsuninstallable. Not 9, because RHEL 9→10 in-place upgrade is unsupported for IdM (leapp excludes IdM servers), so the major chosen now is the major until a new server is built and migrated.The install parameters, which are permanent
No
--setup-dns. Two reasons:namedcosts memory that a 4 GB box does not have spare, and FreeIPA would want to publish_kerberos._udp.cloonar.cominto a zone it does not own. The records aren't needed at all — see below.FreeIPA cannot be renamed after
ipa-server-install(RH solution 174733). The hostname/domain/realm triple is the one thing in this whole design that no revert reaches.Why running alongside mail's KDC is safe
mailalready serves realmCLOONAR.COM. Two KDCs for one realm is normally a race — clients take whichever answers. It is not a race here, because every krb5 client in the fleet has discovery disabled and the KDC pinned by hostname:and
kerberos.nixstates outright that no_kerberosTXT or_kerberos._udpSRV records exist forcloonar.com.So nothing can find the new KDC by accident. The two run in parallel for as long as wanted, and the eventual cutover is three lines of Nix delivered by bento, revertible with
git revert. Cutover is out of scope for this issue — it needs an ADR first, because ADR-0023 explicitly rejected FreeIPA and has a live workstream (#299, #302, #308).LDAP suffix coexistence is likewise fine: FreeIPA's 389DS serves
dc=cloonar,dc=comonipa.cloonar.comwhilemail's OpenLDAP serves the same suffix onldap.cloonar.com. Different hosts, explicitly targeted clients.Deliverable
One command from
nb:Collections (all official, all pinned in
requirements.yml):community.sops(vars plugin, age)freeipa.ansible_freeipa(ipaserverrole)No
hetzner.hcloud. Sub-account creation is deliberately manual — see the security invariant below.Config file shape
Add a
.sops.yamlcreation rule foransible/inventory/host_vars/.*\.sops\.yaml$with&bitwarden &dominik &dominik2 &nb— admin keys only, no host key. Do not modify any existingsecrets.yaml; list what the operator must add and let them runsopsthemselves.Security invariant — do not weaken this
No per-customer secrets file may contain a credential that reaches beyond that customer.
Every secret above is scoped to one server: one sub-account, one repo, one IdM instance. A leaked
host_vars/*.sops.yamlcompromises that customer and stops there.This is why sub-account creation and DNS record creation are manual. Both are automatable —
hetzner.hcloud.storage_box_subaccountexists and works, and the Hetzner DNS token is already in the repo aslego-credentials— but a Cloud API token is project-scoped, not resource-scoped, and a DNS token can mint a certificate for any name in the zone via DNS-01. Automating either would put a fleet-wide credential into a per-customer file.The playbook therefore holds no account-level credential at all. The temptation to "just automate the sub-account too" will come back; this paragraph is the answer.
Roles
preflight— must run first and must be fatalExplicitly requested. Refuse to touch anything unless all of these hold:
ipa_fqdnresolves to the target's IPv4 and IPv6ipa-server-installrejects a CNAME hostnameipa_fqdnborg_remote_pathmatchesFail with a message naming the exact record that is wrong. rDNS is set in netcup's SCP and has no API, so a human has to fix it — the check exists to catch that before
ipa-server-installfails halfway.baseHostname · static IPv6 (netcup is static only — no SLAAC, no DHCPv6, gateway
fe80::1%<iface>; IPv4 comes from netcup's DHCP) · swapfile · packages including FUSE (python3-pyfuse3/fuse3, orborg mountis missing on the one box where restores happen) · firewall, with 389/636 restricted to WireGuard rather than the internet.dnf-automaticin apply mode withexclude=ipa-* 389-ds-* pki-* *tomcat*indnf.conf. This is the highest-value line in the build: unattended CVE patching for the OS, while the one component with no rollback path stays gated.ipa-server-upgraderuns in RPM%posttrans, is documented as "not intended to be executed by end-users", enters maintenance mode, anddnf history undodoes not unwind 389DS schema migrations.borgGenerate the client key on the target, then install it on the sub-account:
Hetzner confirms "This also works in subaccounts." It needs one password-authenticated session — the password is in sops, so use
sshpassoransible.builtin.expect; this stays automated.Three traps:
.pub; port 22 needs RFC4716 (ssh-keygen -e). Getting it wrong presents as an auth failure, not a format error.wait_for/retry, not an assumption.--remote-path=borg-1.4. Hetzner installs borg 1.1, 1.2 (default) and 1.4, chosen per invocation. Without the pin you get 1.2 by accident and a future client bump breaks it silently.Then
borg init --append-only, a backup timer, and client-side helpers mirroringutils/modules/borgbackup.nix(borg-mount,borg-list,borg-restore) against the pushed passphrase file rather than sops-nix.Append-only is advisory here. The sub-account has SFTP, so a compromised host can rewrite the repo's
configand clear the flag. Snapshots are the real control — they are per-Storage-Box, taken by the main account, and invisible to sub-accounts. Setting the snapshot plan is a one-time console step; put "confirm the snapshot plan is active" in the README, because the whole backup threat model rests on it.Record the installed NVRs of
ipa-*,389-ds-*andpki-*into the backup set:ipa-restorerequires the same FQDN and the same package versions, so a restore is impossible without knowing them.freeipaWrap
freeipa.ansible_freeipa'sipaserverrole with the parameters above.ipa-server-installis not idempotent in the way Ansible implies. The role skips when IPA is already installed, but a half-failed install needsipa-server-install --uninstallbefore a retry. Guard it explicitly and document the recovery in the README. Re-running the playbook will not fix a partial install.Enable
ipa-healthcheck.timerand ship its JSON to the existing alloy/Grafana stack.verifyipa-healthcheckclean ·kinitsmoke test ·borg listagainst the real repo ·borg-mountworks.Constraints
ansible/must not go underhosts/.scripts/sync-hostiterates$HOSTS_DIR/*/andscripts/pre-commitrunsls hosts; a non-NixOS entry breaks both. Verified safe at the chosen path:pre-commit's shared regex is anchored (^\.sops\.yaml$, not a suffix match), soansible/**matches neither it nor^hosts/and the hook exits 0. Consider adding anansible-playbook --syntax-check/ansible-lintstep topre-commitso the tree gets some gate, since it currently gets none.secrets.yamlfiles. List required secrets and their location.Manual steps to document in the README
Per server: install AlmaLinux 10 from SCP with the SSH key selected · set rDNS (v4 and v6) · create the Storage Box sub-account, setting the password yourself · create the A/AAAA records · write the two config files.
Once ever: confirm the snapshot plan on
u149513.Acceptance
ansible-playbook … -l ipa-cloonartakes a freshly installed AlmaLinux 10 box to a working IdM server.grepfor an hcloud or DNS token inansible/returns nothing.dnf-automaticapplies an OS security update unattended and provably leavesipa-*,389-ds-*,pki-*,*tomcat*untouched.--remote-path=borg-1.4;borg-mountworks on the target.mail's KDC is untouched andkinitagainst it still works — this issue changes nothing on the existing fleet.Out of scope
Cutover from
mail's KDC (needs an ADR — ADR-0023 rejected FreeIPA and its workstream is live in #299/#302/#308) · migrating the identity tenant out of OpenLDAP · enrollingnb/web-armas IPA clients viasecurity.ipa· replacing FreeIPA's Dogtag-issued web certificate with ACME.