From 146e2dcf24ea73ee70c66b486e25acace1490df2 Mon Sep 17 00:00:00 2001 From: Dan Guido Date: Sun, 3 Aug 2025 17:15:27 -0700 Subject: [PATCH] Fix IPv6 address selection on BSD systems (#14786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Fix IPv6 address selection on BSD systems (#1843) BSD systems return IPv6 addresses in the order they were added to the interface, not sorted by scope like Linux. This causes ansible_default_ipv6 to contain link-local addresses (fe80::) with interface suffixes (%em0) instead of global addresses, breaking certificate generation. This fix: - Adds a new task file to properly select global IPv6 addresses on BSD - Filters out link-local addresses and interface suffixes - Falls back to ansible_all_ipv6_addresses when needed - Ensures certificates are generated with valid global IPv6 addresses The workaround is implemented in Algo rather than waiting for the upstream Ansible issue (#16977) to be fixed, which has been open since 2016. Fixes #1843 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * chore: Remove duplicate condition in BSD IPv6 facts Removed redundant 'global_ipv6_address is not defined' condition that was checked twice in the same when clause. * improve: simplify regex for IPv6 interface suffix removal Change regex from '(.*)%.*' to '%.*' for better readability and performance when stripping interface suffixes from IPv6 addresses. The simplified regex is equivalent but more concise and easier to understand. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: resolve yamllint trailing spaces in BSD IPv6 test Remove trailing spaces from test_bsd_ipv6.yml to ensure CI passes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: resolve yamllint issues across repository - Remove trailing spaces from server.yml, WireGuard test files, and keys.yml - Add missing newlines at end of test files - Ensure all YAML files pass yamllint validation for CI 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- roles/common/tasks/bsd_ipv6_facts.yml | 56 +++++++++++++++++++++++++ roles/common/tasks/freebsd.yml | 4 ++ roles/wireguard/tasks/keys.yml | 4 +- server.yml | 2 +- tests/test-wireguard-async.yml | 2 +- tests/test-wireguard-fix.yml | 4 +- tests/test-wireguard-real-async.yml | 4 +- tests/test_bsd_ipv6.yml | 59 +++++++++++++++++++++++++++ 8 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 roles/common/tasks/bsd_ipv6_facts.yml create mode 100644 tests/test_bsd_ipv6.yml diff --git a/roles/common/tasks/bsd_ipv6_facts.yml b/roles/common/tasks/bsd_ipv6_facts.yml new file mode 100644 index 00000000..8102dc22 --- /dev/null +++ b/roles/common/tasks/bsd_ipv6_facts.yml @@ -0,0 +1,56 @@ +--- +# BSD systems return IPv6 addresses in the order they were added to the interface, +# not sorted by scope like Linux does. This means ansible_default_ipv6 often contains +# a link-local address (fe80::) instead of a global address, which breaks certificate +# generation due to the %interface suffix. +# +# This task file creates a fact with the first global IPv6 address found. + +- name: Initialize all_ipv6_addresses as empty list + set_fact: + all_ipv6_addresses: [] + +- name: Get all IPv6 addresses for the default interface + set_fact: + all_ipv6_addresses: "{{ ansible_facts[ansible_default_ipv6.interface]['ipv6'] | default([]) }}" + when: + - ansible_default_ipv6 is defined + - ansible_default_ipv6.interface is defined + - ansible_facts[ansible_default_ipv6.interface] is defined + +- name: Find first global IPv6 address from interface-specific addresses + set_fact: + global_ipv6_address: "{{ item.address }}" + global_ipv6_prefix: "{{ item.prefix }}" + loop: "{{ all_ipv6_addresses }}" + when: + - all_ipv6_addresses | length > 0 + - item.address is defined + - not item.address.startswith('fe80:') # Filter out link-local addresses + - "'%' not in item.address" # Ensure no interface suffix + - global_ipv6_address is not defined # Only set once + loop_control: + label: "{{ item.address | default('no address') }}" + +- name: Find first global IPv6 address from ansible_all_ipv6_addresses + set_fact: + global_ipv6_address: "{{ item | regex_replace('%.*', '') }}" + global_ipv6_prefix: "128" # Assume /128 for addresses from this list + loop: "{{ ansible_all_ipv6_addresses | default([]) }}" + when: + - global_ipv6_address is not defined + - ansible_all_ipv6_addresses is defined + - not item.startswith('fe80:') + +- name: Override ansible_default_ipv6 with global address on BSD + set_fact: + ansible_default_ipv6: "{{ ansible_default_ipv6 | combine({'address': global_ipv6_address, 'prefix': global_ipv6_prefix}) }}" + when: + - global_ipv6_address is defined + - ansible_default_ipv6 is defined + - ansible_default_ipv6.address.startswith('fe80:') or '%' in ansible_default_ipv6.address + +- name: Debug IPv6 address selection + debug: + msg: "Selected IPv6 address: {{ ansible_default_ipv6.address | default('none') }}" + when: algo_debug | default(false) | bool diff --git a/roles/common/tasks/freebsd.yml b/roles/common/tasks/freebsd.yml index cb8361e2..67e1fa17 100644 --- a/roles/common/tasks/freebsd.yml +++ b/roles/common/tasks/freebsd.yml @@ -16,6 +16,10 @@ - name: Gather additional facts import_tasks: facts.yml +- name: Fix IPv6 address selection on BSD + import_tasks: bsd_ipv6_facts.yml + when: ipv6_support | default(false) | bool + - name: Set OS specific facts set_fact: config_prefix: /usr/local/ diff --git a/roles/wireguard/tasks/keys.yml b/roles/wireguard/tasks/keys.yml index 7ee9789a..ae4b5fb7 100644 --- a/roles/wireguard/tasks/keys.yml +++ b/roles/wireguard/tasks/keys.yml @@ -10,7 +10,7 @@ # # Access pattern: item.item.item where: # - First 'item' = current async_status result -# - Second 'item' = original async job object +# - Second 'item' = original async job object # - Third 'item' = actual username from original loop # # Reference: https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_loops.html @@ -92,7 +92,7 @@ become: false # DATA STRUCTURE EXPLANATION: # item = current result from wg_genkey_results.results - # item.item = original job object from wg_genkey.results + # item.item = original job object from wg_genkey.results # item.item.item = actual username from original loop # item.stdout = the generated private key content diff --git a/server.yml b/server.yml index a5e9070d..b33cad28 100644 --- a/server.yml +++ b/server.yml @@ -9,7 +9,7 @@ - block: - name: Wait until the cloud-init completed wait_for: - path: /var/lib/cloud/data/result.json + path: /var/lib/cloud/data/result.json delay: 10 # Conservative 10 second initial delay timeout: 480 # Reduce from 600 to 480 seconds (8 minutes) sleep: 10 # Check every 10 seconds (less aggressive) diff --git a/tests/test-wireguard-async.yml b/tests/test-wireguard-async.yml index 652540dd..26ca0c3f 100644 --- a/tests/test-wireguard-async.yml +++ b/tests/test-wireguard-async.yml @@ -15,7 +15,7 @@ rc: 0 failed: false finished: true - - item: "10.10.10.1" # This comes from the original wg_genkey.results item + - item: "10.10.10.1" # This comes from the original wg_genkey.results item stdout: "mock_private_key_2" # This is the command output changed: true rc: 0 diff --git a/tests/test-wireguard-fix.yml b/tests/test-wireguard-fix.yml index 05070ad0..07218698 100644 --- a/tests/test-wireguard-fix.yml +++ b/tests/test-wireguard-fix.yml @@ -47,7 +47,7 @@ register: file_check loop: - "testuser1" - - "testuser2" + - "testuser2" - "127.0.0.1" - name: Assert all files exist @@ -63,4 +63,4 @@ state: absent - debug: - msg: "✅ WireGuard async fix test PASSED - item.item.item is the correct pattern!" \ No newline at end of file + msg: "✅ WireGuard async fix test PASSED - item.item.item is the correct pattern!" diff --git a/tests/test-wireguard-real-async.yml b/tests/test-wireguard-real-async.yml index 5bf3f237..e7f19f10 100644 --- a/tests/test-wireguard-real-async.yml +++ b/tests/test-wireguard-real-async.yml @@ -36,7 +36,7 @@ - name: Debug - Show wg_genkey structure debug: var: wg_genkey - + - name: Simulate the actual async pattern - Wait for completion async_status: jid: "{{ item.ansible_job_id }}" @@ -62,4 +62,4 @@ - name: Cleanup file: path: "{{ wireguard_pki_path }}" - state: absent \ No newline at end of file + state: absent diff --git a/tests/test_bsd_ipv6.yml b/tests/test_bsd_ipv6.yml new file mode 100644 index 00000000..043cd5fd --- /dev/null +++ b/tests/test_bsd_ipv6.yml @@ -0,0 +1,59 @@ +--- +# Test playbook for BSD IPv6 address selection +# Run with: ansible-playbook tests/test_bsd_ipv6.yml -e algo_debug=true + +- name: Test BSD IPv6 address selection logic + hosts: localhost + gather_facts: no + vars: + # Simulate BSD system facts with link-local as default + ansible_default_ipv6: + address: "fe80::1%em0" + interface: "em0" + prefix: "64" + gateway: "fe80::1" + + # Simulate interface facts with multiple IPv6 addresses + ansible_facts: + em0: + ipv6: + - address: "fe80::1%em0" + prefix: "64" + - address: "2001:db8::1" + prefix: "64" + - address: "2001:db8::2" + prefix: "64" + + # Simulate all_ipv6_addresses fact + ansible_all_ipv6_addresses: + - "fe80::1%em0" + - "2001:db8::1" + - "2001:db8::2" + + ipv6_support: true + algo_debug: true + + tasks: + - name: Show initial IPv6 facts + debug: + msg: "Initial ansible_default_ipv6: {{ ansible_default_ipv6 }}" + + - name: Include BSD IPv6 fix tasks + include_tasks: ../roles/common/tasks/bsd_ipv6_facts.yml + + - name: Show fixed IPv6 facts + debug: + msg: | + Fixed ansible_default_ipv6: {{ ansible_default_ipv6 }} + Global IPv6 address: {{ global_ipv6_address | default('not found') }} + Global IPv6 prefix: {{ global_ipv6_prefix | default('not found') }} + + - name: Verify fix worked + assert: + that: + - ansible_default_ipv6.address == "2001:db8::1" + - global_ipv6_address == "2001:db8::1" + - "'%' not in ansible_default_ipv6.address" + - not ansible_default_ipv6.address.startswith('fe80:') + fail_msg: "BSD IPv6 address selection failed" + success_msg: "BSD IPv6 address selection successful"