OPNET-772: Add NAT64/DNS64 support for IPv6-only clusters on IPv4-only hosts - #1952
OPNET-772: Add NAT64/DNS64 support for IPv6-only clusters on IPv4-only hosts#1952emy wants to merge 3 commits into
Conversation
Allow running an IPv6-only OpenShift cluster (IP_STACK=v6) on an IPv4-only host (HOST_IP_STACK=v4) by adding NAT64 (TAYGA) and DNS64 (unbound) plumbing, enabled with ENABLE_NAT64=true. nat64.sh provides the helpers, sourced by 02_configure_host.sh: bring IPv6 up on the baremetal bridge, configure the TAYGA NAT64 tunnel, run an unbound DNS64 resolver (dns64-synthall, since the host has no native IPv6 egress) and point the host and every cluster-facing libvirt network at it, and rewrite node BMC addresses to the IPv6 baremetal address so the IPv6-only in-cluster Ironic can reach them after pivot. common.sh validates the IP_STACK=v6 / HOST_IP_STACK=v4 requirement, 01_install_requirements.sh installs tayga and unbound, and host_cleanup.sh/ocp_cleanup.sh tear the configuration down.
metal3-dev-env only puts the IPv4 baremetal address in the sushy-tools BMC emulator certificate SAN. With NAT64 the in-cluster Ironic pods are IPv6-only and reach the BMC over IPv6, and on OCP >= 4.22 dev-scripts no longer emits disableCertificateVerification, so certificate verification is always on and the IPv6 redfish connection is rejected with an IP address mismatch. Add nat64_fixup_sushy_cert (called from 02_configure_host.sh before step 05 embeds the cert into the install-config trust bundle) to regenerate the cert with both the IPv4 and IPv6 SANs, reusing the existing key, and restart sushy-tools. Idempotent and a no-op when sushy or the IPv6 address is absent.
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Hi @emy. Thanks for your PR. I'm waiting for a openshift-metal3 member to verify that this patch is reasonable to test. If it is, they should reply with Tip We noticed you've done this a few times! Consider joining the org to skip this step and gain Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
|
/ok-to-test |
Deep Review — Multi-Specialist PanelDisposition: REQUEST_CHANGES Reviewed The design is sound and the hard parts — Specialist FindingsFunctional bugs — 2 blocking, 6 suggestions, 4 notes
Adversarial — 5 blocking, 3 suggestions
Security — 6 blocking, 3 suggestions (2 downgraded on verification)
Architecture — 5 blocking, 1 suggestion
Consistency — 3 blocking, 3 suggestions, 1 note
QA — 5 blocking, 1 suggestion, 1 note
Technical writer — 1 blocking, 3 suggestions (1 finding discarded)
Panel SynthesisConvergence was high: Two BLOCKING security findings were refuted by their own reproducers and downgraded — worth stating plainly, because both read as serious until tested. Conversely, the single highest-impact finding ( Required Actions
Question for the author
Optional Follow-ups
Stats
Reproducers ran read-only against Generated by /code-review:deep-review. This message was generated using AI. Please verify before acting on it. |
mkowalski
left a comment
There was a problem hiding this comment.
Deep Review — inline findings
Line-anchored companion to the full panel verdict. Disposition: REQUEST_CHANGES.
Reviewed 28a095e against merge base e2790c1. Seven specialists reviewed independently; every BLOCKING claim asserting a runtime bug went to a separate reproducer agent. 8 reproducers ran, 8 confirmed — 2 with corrections that lowered severity, noted inline where relevant.
23 inline comments below, grouped by what they block:
- Breaks a default
ENABLE_NAT64=truedeploy —network.sh:158(installer pointed at a registry that is never created) - Breaks re-running
02_configure_host.sh—nat64.sh:49 - Breaks an existing cluster's BMC trust —
nat64.sh:239(cert rotates every run, invalidatingbmcVerifyCA) - Breaks on a supported distro —
01_install_requirements.sh:216(EL10 has no EPEL, andtaygaships only there) - Breaks after reboot, silently —
nat64.sh:68+nat64.sh:177(DNS64 persists, translator and libvirt autostart do not, so DNS answers succeed while traffic blackholes) - Destructive to unrelated host state —
nat64.sh:275(rm -rf /etc/corednson every configure run) - Conditional on
BMC_DRIVER—nat64.sh:194(ipmi endpoints rewritten to IPv6; vbmc listens on IPv4 only) - Cleanup and scope —
host_cleanup.sh:37(teardown gated on current config),common.sh:436(validation runs before agent scenarios deriveIP_STACK), plus an unrelated revert of merged commite741cb5inhost_cleanup.shandocp_cleanup.sh
The rest are suggestions and notes: firewall rule scope/idempotence, cleanup ordering, DNS fallback, docs.
One finding I could not anchor inline
network.sh:190 is outside the diff, so it has no inline comment. PROVISIONING_HOST_EXTERNAL_IP keys off HOST_IP_STACK, which is v4 under NAT64, so it resolves to the IPv4 baremetal address. Consumers include NTP_SERVERS (utils.sh:126), bootstrapExternalStaticGateway/bootstrapExternalStaticDNS (ocp_install_env.sh:111,115) and the agent DHCP option (agent/05_agent_configure.sh:685). Is handing IPv6-only nodes an IPv4 address there intentional — reached via NAT64 — or should NAT64 introduce a distinct cluster-facing address? Not reproduced; flagging for your judgement rather than asserting a bug.
Two claims that did not survive verification
Stating these plainly so they don't cost you time:
- The
ip6tablesrules at02_configure_host.sh:487-488were initially filed as a NAT64 isolation bypass. That framing is wrong — on a real host theFORWARDpolicy is alreadyaccept,-Acannot override an earlier DROP, firewalld filters in a separate nft base chain atpriority filter + 10, and these rules sit behindLIBVIRT_FWOand matched 0 packets. Reduced to hygiene (scope, idempotence, missing cleanup). - The BMC temp-file
mvwas filed as widening credential permissions. The mechanism is real (0600 → 0644) but the premise is not:NODES_FILEis already 0644 at birth from metal3-dev-env'stemplatetask. Reduced to defensive hygiene.
Reproducers ran read-only in throwaway namespaces and containers; the checkout was verified unmodified afterwards.
Generated by /code-review:deep-review. This message was generated using AI. Please verify before acting on it.
| if [[ "${ENABLE_NAT64}" != "true" ]] && [[ ${AGENT_E2E_TEST_BOOT_MODE} != "ISO_NO_REGISTRY" ]]; then | ||
| # NAT64 provides external registry access, so mirroring is not required | ||
| export MIRROR_IMAGES=${MIRROR_IMAGES:-true} |
There was a problem hiding this comment.
Bug: ENABLE_NAT64 leaves MIRROR_IMAGES empty, so the installer is pointed at a registry that is never created
This guard stops export MIRROR_IMAGES=${MIRROR_IMAGES:-true} from running, leaving MIRROR_IMAGES as the empty string set in common.sh:80. But common.sh:672 ([[ "${MIRROR_IMAGES,,}" != "false" ]] && [[ ... || "$IP_STACK" = "v6" ]]) is evaluated before network.sh is sourced — every script does source common.sh then source network.sh — and empty is not "false", so OPENSHIFT_INSTALL_RELEASE_IMAGE_OVERRIDE is still exported pointing at the local registry.
Meanwhile 04_setup_ironic.sh:109 ([[ ! -z "${MIRROR_IMAGES}" && ... ]]) is false, so setup_release_mirror and the podman push never run. use_registry() (utils.sh:1144) uses the same ! -z gate, so 02_configure_host.sh:139 never even starts the registry container.
Note the guard is only reachable in the broken case: a user who sets MIRROR_IMAGES=false explicitly already gets the intended behaviour without this change.
Fix: set the value explicitly — export MIRROR_IMAGES=${MIRROR_IMAGES:-false} in the NAT64 branch — or move the NAT64 exclusion into common.sh above line 672.
Reproducer
Steps: Sourced the real common.sh then network.sh from this branch in the real 04_setup_ironic.sh order, with the mirroring gates extracted from the files at runtime via sed so they cannot drift. Ran with ENABLE_NAT64=true/false as the only difference.
Expected: the two decisions agree — either mirroring is off and the override points upstream, or mirroring runs and populates the registry.
Actual: xtrace from the real scripts:
++(common.sh:672): [[ '' != \f\a\l\s\e ]]
++(common.sh:672): [[ v6 = \v\6 ]]
++(common.sh:691): export OPENSHIFT_INSTALL_RELEASE_IMAGE_OVERRIDE=virthost.ostest.test.metalkube.org:5000/localimages/local-release-image:latest
++(network.sh:158): [[ true != \t\r\u\e ]]
MIRROR_IMAGES=[], MIRRORING_BLOCK_RUNS=no, USE_REGISTRY=no, override still .../local-release-image:latest. Control run with ENABLE_NAT64=false is self-consistent (MIRROR_IMAGES=true, mirroring runs, registry started). 7/7 assertions passed.
| # Create the TUN device via TAYGA | ||
| sudo tayga --mktun |
There was a problem hiding this comment.
Bug: configure_tayga is not idempotent — re-running 02_configure_host.sh aborts here
Every other command in this function is deliberately re-runnable (mkdir -p, ip route add ... || true, iptables -C ... || iptables -A ...), but sudo tayga --mktun has no guard. 02_configure_host.sh runs with set -euxo pipefail and calls configure_tayga unconditionally at line 556, so any second run — make re-run after a later failure, a very common dev-scripts workflow — aborts here. cleanup_nat64 is only reachable from host_cleanup.sh, so nothing tears TAYGA down in between.
Compare 04_setup_ironic.sh:234, which guards container creation with if ! is_running sushy-tools.
Fix: [[ -d /sys/class/net/${NAT64_TUN_INTERFACE} ]] || sudo tayga --mktun, or better, use the tayga@.service unit the RPM already ships (see the separate comment on line 68).
Reproducer
Steps: Extracted configure_tayga verbatim, ran it twice under the same set -euxo pipefail, inside a throwaway network namespace with the real network.sh defaults. tayga-0.9.2-17.el9 installed from EPEL.
Expected: second run reconfigures and continues on to configure_dns64 and nat64_fixup_sushy_cert.
Actual: run 1 exit 0; run 2 exit 1:
+ sudo tayga --config .../tayga.conf --mktun
Unable to attach tun device nat64, aborting: Device or resource busy
MARKER_REACHED_configure_dns64 never printed — host setup aborts partway through NAT64 setup. Control proves the fix: ip link show nat64 >/dev/null 2>&1 || tayga --mktun exits 0.
Root cause: the daemon started at line 68 holds the TUN queue open, so TUNSETIFF returns EBUSY for any second attach. Boundary condition: --mktun is idempotent when the tun exists but no daemon holds it — the failure requires a live daemon, which is exactly the steady state after a successful run.
| # Already valid for the IPv6 address? Nothing to do. | ||
| if sudo openssl x509 -in "${cert}" -noout -text 2>/dev/null | grep -qiF "${v6host}"; then | ||
| echo "sushy BMC cert already valid for ${v6host}" | ||
| return 0 | ||
| fi |
There was a problem hiding this comment.
Bug: this idempotence check can never match, so the sushy cert is rotated on every run
OpenSSL renders IPv6 SANs uncompressed (ipaddr_to_asc() emits each group with %X and no :: compression), while $v6host comes from nth_ip → ansible.utils.nthhost → netaddr, which returns the compressed form. So grep -qiF never matches even when the SAN is present and valid. Case is not the problem — grep -i handles that; zero-run compression is.
This matters because ocp_install_env.sh:482-483 embeds this exact file into the install-config bmcVerifyCA. Rotating it invalidates the trust anchor already baked into an existing install-config or installed cluster, and contradicts the "Idempotent" docstring at line 224.
Fix: sudo openssl x509 -in "${cert}" -noout -checkip "${v6host}" — and ideally add the IPv6 SAN where the cert is originally generated rather than post-processing it.
Reproducer
Steps: Extracted nat64_fixup_sushy_cert (lines 226-266) and nth_ip (network.sh:6-11) verbatim, seeded the metal3-dev-env starting state (key + IPv4-only-SAN cert), then invoked the function 3× with the real defaults.
Expected: run 2 detects the SAN, prints "already valid", leaves the cert byte-identical.
Actual: printed "Regenerating..." every time; serial and SHA-256 changed on all 3 runs.
X509v3 Subject Alternative Name:
IP Address:192.168.111.1, IP Address:FD2E:6F44:5DD8:C956:0:0:0:1
v6host searched for : fd2e:6f44:5dd8:c956::1
grep -qiF => NOMATCH
The SAN was valid the whole time: openssl verify -verify_ip fd2e:6f44:5dd8:c956::1 → OK, and cryptography parses it as containing that exact address.
Root-cause control (proves compression, not case): subnet 2001:db8:1:2:3:4:5:0/120 → host has no zero-run → renders identically → MATCH. The real default → NOMATCH.
| if [[ "${ENABLE_NAT64:-false}" == "true" ]]; then | ||
| echo "Installing NAT64 dependencies (TAYGA, unbound)..." | ||
| # TAYGA provides the NAT64 translation; unbound provides DNS64 synthesis. | ||
| # unbound is used rather than CoreDNS because its built-in dns64 module | ||
| # reliably synthesizes AAAA records (the CoreDNS dns64 plugin build did not). | ||
| sudo dnf -y install tayga unbound |
There was a problem hiding this comment.
Bug: this fails on a clean EL10 host — tayga ships only in EPEL, which the EL10 branch never enables
centos10/rhel10 are an explicitly supported branch (line 111; the catch-all at 119-121 exits 1 for anything else), but only the EL9 branch installs EPEL (line 97 epel-release, line 106 the EPEL-9 RPM). The EL10 branch (111-118) only enables CRB via subscription-manager ... || true. This install sits after the case, so it runs on every branch. Under set -euxo pipefail (line 2) the failure aborts the whole script.
Nothing else enables EPEL first: the only other reference is agent/01_agent_requirements.sh:58 (different script, gated on AGENT_E2E_TEST_BOOT_MODE), and the pinned metal3-dev-env packages_installation role has no epel/tayga references.
Fix: enable EPEL 10 in the EL10 branch — dnf -y install epel-release (CentOS Stream) or the EPEL-10 release RPM (RHEL) — or explicitly reject NAT64 on EL10. unbound is fine; it is in BaseOS/AppStream on both.
Reproducer
Steps: Queried remote repodata with dnf repoquery --repofrompath against mirror.stream.centos.org and dl.fedoraproject.org, then reproduced end-to-end in a clean quay.io/centos/centos:stream10 container using the EL10 branch semantics (CRB on, no EPEL).
Expected: dnf -y install tayga unbound succeeds, as it does on EL9.
Actual:
Error: Unable to find a match: tayga
DNF_EXIT_CODE=1
tayga: NOT FOUND in enabled repos
unbound: FOUND
| repo | tayga | unbound |
|---|---|---|
| cs10-BaseOS / AppStream / CRB | ABSENT | present |
| epel10 | tayga-0.9.6-...el10_2 | absent |
| cs9-BaseOS / AppStream / CRB | ABSENT | present |
| epel9 | tayga-0.9.2-17.el9 | absent |
Correction to one panel claim: EPEL 10 does ship tayga, and epel-release + dnf -y install tayga unbound on EL10 returns RC=0. So enabling EPEL is a sufficient fix.
| # Start TAYGA daemon | ||
| sudo tayga |
There was a problem hiding this comment.
Bug: TAYGA is started as an unmanaged daemon, so NAT64 does not survive a host reboot — and fails silently when it doesn't
The DNS64 half is fully persistent: unit file with WantedBy=multi-user.target (119-132), systemctl enable --now (135), the NetworkManager drop-in (138-141), and the libvirt dnsmasq option persisted via net-define (179). The translator half is entirely runtime-only: this bare forked daemon, plus ip route add (55), ip -6 route add (58), sysctl -w (21-22, 61) and iptables -A (64-65).
After a reboot unbound comes back and keeps synthesizing AAAA inside ${NAT64_PREFIX}, and the libvirt dnsmasq keeps forwarding to it, but there is no translator and no route — every synthesized address blackholes behind a successful DNS answer. That is the worst failure shape: it looks like DNS works.
Worth knowing: the tayga RPM you install already ships /usr/lib/systemd/system/tayga@.service (ExecStart=/usr/sbin/tayga --pidfile ... -d --config /etc/tayga/%i.conf). It is never enabled here, and it reads /etc/tayga/<instance>.conf — not the /etc/tayga.conf written at line 5. Using it (or an equivalent unit owning the TUN, routes and firewall state) fixes reboot persistence and the idempotence problem on line 49 at once.
Reproducer
Steps: Applied exactly what configure_tayga applies inside a throwaway netns, then destroyed and recreated the namespace — routes, tun devices, net.* sysctls and iptables are all namespaced, so this resets precisely the state a reboot resets. Separately used systemctl --root=<tmpdir> so the host was never touched.
Expected: both halves of the NAT64/DNS64 pair have matching lifetimes.
Actual:
before reset: tun=1 v4route=1 v6route=1 ipv6fwd=1 masq=1
after reset: tun=0 v4route=0 v6route=0 ipv6fwd=0 masq=0
ip -6 route get 64:ff9b::102:304 -> RTNETLINK answers: Network is unreachable
ping6 64:ff9b::102:304 -> connect: Network is unreachable
(64:ff9b::102:304 is exactly what dns64-synthall synthesizes for an A record.)
systemctl --root enable unbound-dns64.service → creates multi-user.target.wants/ symlink, disabled→enabled. is-enabled tayga.service → No such file or directory. 6/6 checks passed.
The end-to-end post-reboot blackhole was not executed (no reboot performed); it follows by composition of the runtime-verified facts above.
| # net-destroy drops the bridge; restore a dummy for carrier and addr_gen_mode=0 | ||
| # so the network's IPv6 address comes up before the VMs provide carrier (needed | ||
| # for IPv6 on EL9). | ||
| sudo ip link add name "${net}-dmy" up master "${net}" type dummy 2>/dev/null || true |
There was a problem hiding this comment.
Suggestion: ${net}-dmy can exceed the 15-character interface name limit
With the default CLUSTER_NAME=ostest the name is ostestbm-dmy (12 chars) and this is fine. A CLUSTER_NAME of 12+ characters pushes ${net}-dmy past Linux's 15-char IFNAMSIZ limit; || true then hides the failure and the recreated IPv6 bridge is left without carrier before the VMs boot — which is the exact condition this line exists to prevent.
Fix: derive a fixed or truncated dummy name bounded to 15 characters, and don't swallow the failure.
| tmp="${f}.nat64" | ||
| # Literal (non-regex) host replacement via split/join on the "//host:" token. | ||
| jq --arg old "//${v4host}:" --arg new "//[${v6host}]:" \ | ||
| '(.nodes[]?.driver_info.address) |= (. / $old | join($new))' \ | ||
| "${f}" > "${tmp}" && mv "${tmp}" "${f}" |
There was a problem hiding this comment.
Suggestion: preserve file mode across the mv, and don't abort on a node without a BMC address
Two small robustness issues in this loop:
-
Mode is not preserved. The temp file is created with the current umask and
mv(rename) carries its mode over the destination. I measured 0600 → 0644 under umask 0022, and it fires even when the rewrite matches nothing. To be clear about impact: this is not exploitable in the default flow — metal3-dev-env'stemplatetask sets nomode:, soNODES_FILEis already 0644 at birth andWORKING_DIRischmod 755(common.sh:355). It matters only for a user-suppliedNODES_FILE(config_example.sh:546) deliberately protected at 0600 with real BMC credentials. Cheap defensive hygiene:install -m "$(stat -c %a "$f")"orchmod --reference="$f"before themv. -
jqaborts on a missing address.(.nodes[]?.driver_info.address) |= (. / $old | join($new))applies to anullwhen the path is absent:jq: error ... null (null) and string cannot be divided, exit 5. The&& mvmeans the loop continues but leaves${f}.nat64behind, and the non-zero return then aborts02_configure_host.shat line 301. Stock generated files always have the key, so this only bites user-supplied inventories. Fix:select(.driver_info.address? != null)andrm -fthe temp on failure.
|
|
||
| sudo rm -rf "/etc/NetworkManager/dnsmasq.d/openshift-${CLUSTER_NAME}.conf" | ||
| sudo rm -rf /etc/NetworkManager/dnsmasq.d/openshift-${CLUSTER_NAME}.conf | ||
| sudo rm -f /etc/NetworkManager/dnsmasq.d/nat64-dns64.conf |
There was a problem hiding this comment.
Suggestion: make redeploy never restores this, and the constant is now duplicated
make redeploy is ocp_cleanup ironic_cleanup build_installer ironic install_config ocp_run — 02_configure_host.sh, the only writer of ${NAT64_DNSMASQ_CONF}, is not re-run. So the first redeploy of a NAT64 environment permanently removes host DNS64 forwarding.
Three smaller points: this is ungated (it runs even when ENABLE_NAT64 is false), it hardcodes the literal instead of using ${NAT64_DNSMASQ_CONF} (ocp_cleanup.sh does not source nat64.sh, so the constant now lives in two files and can drift), and unlike cleanup_nat64 it does not reload NetworkManager, so the running dnsmasq keeps the deleted config until some unrelated later reload.
Fix: drop this line and leave host-level NAT64 state to cleanup_nat64, or source nat64.sh, gate it, use the variable, and reload NetworkManager.
| # ENABLE_NAT64 - | ||
| # Enable NAT64/DNS64 to allow IPv6-only clusters (IP_STACK=v6) to run on | ||
| # IPv4-only hosts (HOST_IP_STACK=v4). Uses TAYGA for NAT64 translation and | ||
| # CoreDNS for DNS64 synthesis, enabling cluster VMs to reach external IPv4 |
There was a problem hiding this comment.
Suggestion: this names the wrong DNS64 implementation
The implementation installs and runs unbound (01_install_requirements.sh:216, nat64.sh:99-135) and treats CoreDNS as legacy to be removed (nat64.sh:269-277). Anyone following this comment will inspect the wrong service when troubleshooting.
While here, three other doc gaps worth closing in the same block:
NAT64_V6_ADDR(network.sh:128-129) is a supported override but is not documented.- Line 432 says
EXTERNAL_SUBNET_V6is empty forHOST_IP_STACK=v4; under NAT64 it defaults tofd2e:6f44:5dd8:c956::/120. - The later claim that IPv6 defaults
MIRROR_IMAGESto true is also not true under NAT64.
Most importantly, enabling this disables any existing unbound.service, redirects host and libvirt DNS, enables forwarding, adds routes and firewall rules, rewrites BMC data and replaces the sushy certificate — and cleanup restores none of that and only runs while ENABLE_NAT64 is still true. That is worth stating here explicitly.
| if [[ "${ENABLE_NAT64}" == "true" ]]; then | ||
| export NAT64_V6_ADDR=${NAT64_V6_ADDR:-$(nth_ip "$EXTERNAL_SUBNET_V6" 3)} |
There was a problem hiding this comment.
Note: NAT64_V6_ADDR defaults to an address that is on-link on the baremetal bridge
nth_ip "$EXTERNAL_SUBNET_V6" 3 is fd2e:6f44:5dd8:c956::3 with the default subnet — inside the /120 libvirt puts on the baremetal bridge. The host therefore has an on-link route for it via ${BAREMETAL_NETWORK_NAME} rather than via the nat64 tun. TAYGA uses this address as the source of the ICMPv6 errors it generates (unreachable, packet-too-big/PMTUD), so those replies and neighbour resolution get directed at the bridge where nothing answers.
The index itself is free (::1 virthost, ::2 DNS VIP, ::4 ingress, ::5 api, ::7/::8 provisioning, ::9 bootstrap, ::20-::60 DHCP), so this is about routing, not collision. Worth a deliberate default outside any locally-attached subnet, plus documenting the knob in config_example.sh.
…cert + BMC rewrite, idempotent firewall/cleanup, and config validation
|
@emy: The following tests failed, say
Full PR test history. Your PR dashboard. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here. |
Adds optional NAT64 (TAYGA) + DNS64 (unbound) support so an IPv6-only cluster
(IP_STACK=v6) can be deployed on an IPv4-only host (HOST_IP_STACK=v4). Enable
with ENABLE_NAT64=true; it is a no-op otherwise.
The helpers live in nat64.sh (sourced by 02_configure_host.sh): bring IPv6 up on
the baremetal bridge, configure the TAYGA NAT64 tunnel, run an unbound DNS64
resolver (dns64-synthall, since the host has no native IPv6 egress) and point the
host and every cluster-facing libvirt network at it, and rewrite node BMC
addresses to the IPv6 baremetal address so the IPv6-only in-cluster Ironic can
reach them after pivot. The second commit regenerates the sushy-tools BMC cert
with the IPv6 SAN, required on OCP >= 4.22 where certificate verification is
always on.
01_install_requirements.sh installs tayga/unbound, common.sh validates the
IP_STACK/HOST_IP_STACK requirement, and host_cleanup.sh/ocp_cleanup.sh tear the
configuration down.