From 5c416f8578a11a1009d4b65d04245b26059ace29 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 29 Oct 2026 11:00:51 +0100 Subject: [PATCH 1/8] feat(ansible): nas-2 trusts the SSH user CA nas-2 (Synology DSM 7.3) joins [ssh_ca] under a new [synology_nas] group, so claude-bot reaches it with its 1h certificate like every other host. DSM differs from the fleet in ways the ssh-ca role now handles: - Python 3.8 only; plays/configure-synology-python.yml installs Synology's Python3.9 package via raw, and group_vars point the interpreter at it. - No getent: admin logins come from /etc/passwd on every host. - No useradd: the account is made with synouser and added to administrators (the only group DSM lets in over SSH). synogroup --member replaces the list, so the current members are read and passed along, and the run stops if the owner login is not among them. - No visudo: the sudo rules are rendered and checked on the controller. - sshd_config has no Include; the role puts it first. - No sftp/scp for ladmin: transfers are piped. Co-Authored-By: Claude Opus 5.5 --- .../group_vars/synology_nas/vars.yml | 5 + ansible/inventory/pve_hosts.yml | 6 + ansible/plays/configure-synology-python.yml | 19 +++ .../roles/ssh-ca/tasks/authorized_keys.yml | 16 +- ansible/roles/ssh-ca/tasks/main.yml | 141 ++++++++++++++++-- ansible/roles/ssh-ca/tasks/report.yml | 7 +- ansible/site.yml | 2 + 7 files changed, 169 insertions(+), 27 deletions(-) create mode 100644 ansible/inventory/group_vars/synology_nas/vars.yml create mode 100644 ansible/plays/configure-synology-python.yml diff --git a/ansible/inventory/group_vars/synology_nas/vars.yml b/ansible/inventory/group_vars/synology_nas/vars.yml new file mode 100644 index 00000000..889ceb81 --- /dev/null +++ b/ansible/inventory/group_vars/synology_nas/vars.yml @@ -0,0 +1,5 @@ +--- +# DSM's own python3 is 3.8; plays/configure-synology-python.yml installs this. +ansible_python_interpreter: /var/packages/Python3.9/target/usr/bin/python3.9 +# DSM offers no sftp/scp to ladmin. +ansible_ssh_transfer_method: piped diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index 8a8a0fd0..8482fb2a 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -25,6 +25,11 @@ proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root [edge_host] edge-1.iot.renner.uno ansible_user=root +# Off-site Synology DS1522+ (DSM). ladmin is the only admin login; become +# needs its password. +[synology_nas] +nas-2.renner.uno ansible_user=ladmin + # Capability groups. Membership states which hardware a host physically has, # and each service playbook targets the group for its own concern. No flags and # no `when:` on the roles -- moving a stick to another box is a one-line change @@ -70,6 +75,7 @@ proxy-1.renner.uno proxy-2.renner.uno netbird.renner.uno edge-1.iot.renner.uno +nas-2.renner.uno # Hosts whose admin logins accept only ssh_admin_authorized_keys (break-glass); # everything else needs a CA certificate. Members must be in [ssh_ca]. diff --git a/ansible/plays/configure-synology-python.yml b/ansible/plays/configure-synology-python.yml new file mode 100644 index 00000000..cfb716d5 --- /dev/null +++ b/ansible/plays/configure-synology-python.yml @@ -0,0 +1,19 @@ +--- +# Ansible needs Python >= 3.9 on the target; DSM ships 3.8. Synology's +# Python3.9 package installs beside it. raw, because the modules cannot run +# before it is there; raw is skipped in check mode. +- name: Install Python 3.9 on the Synology NAS + hosts: synology_nas + become: true + gather_facts: false + tasks: + - name: Install the Python3.9 package + ansible.builtin.raw: >- + if [ -x {{ ansible_python_interpreter }} ]; then echo present; + else /usr/syno/bin/synopkg install_from_server Python3.9; fi + register: synology_python_install + changed_when: "'present' not in synology_python_install.stdout" + + - name: Check the interpreter exists + ansible.builtin.raw: test -x {{ ansible_python_interpreter }} + changed_when: false diff --git a/ansible/roles/ssh-ca/tasks/authorized_keys.yml b/ansible/roles/ssh-ca/tasks/authorized_keys.yml index c52f11a9..5b1a49cd 100644 --- a/ansible/roles/ssh-ca/tasks/authorized_keys.yml +++ b/ansible/roles/ssh-ca/tasks/authorized_keys.yml @@ -1,23 +1,15 @@ --- # Replaces the admin logins' authorized_keys with ssh_admin_authorized_keys # (break-glass + CI). Everything else logs in with a CA certificate. -- name: Look up the admin logins - ansible.builtin.getent: - database: passwd - key: "{{ item }}" - fail_key: false - loop: "{{ ssh_ca_owner_users }}" - register: ssh_ca_admin_lookup - +# ssh_ca_admin_logins comes from main.yml, which runs first in the same play. - name: Set the admin logins' authorized_keys to the managed list ansible.posix.authorized_key: - user: "{{ item.item }}" + user: "{{ item[0] }}" key: | {% for k in ssh_admin_authorized_keys %} {{ (k.options ~ ' ') if (k.options | default('')) else '' }}{{ k.key }} {% endfor %} exclusive: true - loop: "{{ ssh_ca_admin_lookup.results }}" + loop: "{{ ssh_ca_admin_logins }}" loop_control: - label: "{{ item.item }}" - when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none + label: "{{ item[0] }}" diff --git a/ansible/roles/ssh-ca/tasks/main.yml b/ansible/roles/ssh-ca/tasks/main.yml index 057ab954..905b8096 100644 --- a/ansible/roles/ssh-ca/tasks/main.yml +++ b/ansible/roles/ssh-ca/tasks/main.yml @@ -34,6 +34,26 @@ group: root mode: "0644" +# /etc/passwd, not getent: DSM has no getent binary. The admin logins are +# local accounts. Each entry is the split passwd line (0 name, 5 home). +- name: Read the local accounts + ansible.builtin.slurp: + src: /etc/passwd + register: ssh_ca_passwd_file + +- name: Find the existing admin logins + vars: + ssh_ca_passwd: "{{ ssh_ca_passwd_file.content | b64decode | trim | split('\n') | map('split', ':') | list }}" + ansible.builtin.set_fact: + ssh_ca_admin_logins: "{{ ssh_ca_passwd | selectattr('0', 'in', ssh_ca_owner_users) | list }}" + ssh_ca_claude_exists: "{{ ssh_ca_claude_user in ssh_ca_passwd | map('first') }}" + +# DSM manages accounts with synouser/synogroup and has no useradd. +- name: Detect DSM + ansible.builtin.stat: + path: /usr/syno/sbin/synouser + register: ssh_ca_synouser + # systemd-journal: read every journal without sudo (see sudoers-claude.j2). - name: Create the Claude account ansible.builtin.user: @@ -43,14 +63,57 @@ password: "!" groups: systemd-journal append: true + when: not ssh_ca_synouser.stat.exists -- name: Look up the owner admin logins - ansible.builtin.getent: - database: passwd - key: "{{ item }}" - fail_key: false - loop: "{{ ssh_ca_owner_users }}" - register: ssh_ca_owner_lookup +- name: Create the Claude account on DSM + when: ssh_ca_synouser.stat.exists + block: + # synouser requires a password; nobody knows it and sshd refuses passwords. + - name: Create the DSM account + ansible.builtin.command: + argv: + - /usr/syno/sbin/synouser + - --add + - "{{ ssh_ca_claude_user }}" + - "{{ lookup('ansible.builtin.password', '/dev/null', length=40, chars=['ascii_letters', 'digits']) }}" + - Claude Bot + - "0" + - "" + - "0" + # command runs in check mode here. + when: + - not ssh_ca_claude_exists + - not ansible_check_mode + no_log: true + + # Members are listed as ":[]". + - name: Read the administrators members + ansible.builtin.command: /usr/syno/sbin/synogroup --get administrators + register: ssh_ca_dsm_admins_out + changed_when: false + check_mode: false + + - name: Find the administrators members + ansible.builtin.set_fact: + ssh_ca_dsm_admins: >- + {{ ssh_ca_dsm_admins_out.stdout_lines | select('match', '^[0-9]+:\[.*\]$') + | map('regex_replace', '^[0-9]+:\[(.*)\]$', '\1') | list }} + + # synogroup --member replaces the member list; a parse that lost the owner + # admin login would drop it. + - name: Check the member list holds the owner admin login + ansible.builtin.assert: + that: + - ssh_ca_dsm_admins | intersect(ssh_ca_owner_users) | length > 0 + fail_msg: "administrators members lack {{ ssh_ca_owner_users }}: {{ ssh_ca_dsm_admins }}" + + # DSM allows SSH for administrators only. + - name: Add the Claude account to administrators + ansible.builtin.command: + argv: "{{ ['/usr/syno/sbin/synogroup', '--member', 'administrators'] + ssh_ca_dsm_admins + [ssh_ca_claude_user] }}" + when: + - ssh_ca_claude_user not in ssh_ca_dsm_admins + - not ansible_check_mode - name: Create the principals directory ansible.builtin.file: @@ -63,14 +126,13 @@ - name: Accept the owner principal for the existing admin logins ansible.builtin.copy: content: "{{ ssh_ca_owner_principal }}\n" - dest: "/etc/ssh/auth_principals/{{ item.item }}" + dest: "/etc/ssh/auth_principals/{{ item[0] }}" owner: root group: root mode: "0644" - loop: "{{ ssh_ca_owner_lookup.results }}" + loop: "{{ ssh_ca_admin_logins }}" loop_control: - label: "{{ item.item }}" - when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none + label: "{{ item[0] }}" - name: Accept the Claude principal for the Claude account ansible.builtin.copy: @@ -88,6 +150,63 @@ group: root mode: "0440" validate: visudo -cf %s + when: not ssh_ca_synouser.stat.exists + +# DSM has no visudo, so the rules are rendered and checked on the controller. +- name: Install the Claude sudo rules on DSM + when: ssh_ca_synouser.stat.exists + block: + - name: Create a local file for the sudo rules + ansible.builtin.tempfile: + suffix: .sudoers + delegate_to: localhost + become: false + check_mode: false + register: ssh_ca_sudoers_local + + - name: Render and check the sudo rules on the controller + ansible.builtin.template: + src: sudoers-claude.j2 + dest: "{{ ssh_ca_sudoers_local.path }}" + mode: "0600" + validate: visudo -cf %s + delegate_to: localhost + become: false + check_mode: false + + - name: Install the checked sudo rules + ansible.builtin.copy: + src: "{{ ssh_ca_sudoers_local.path }}" + dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}" + owner: root + group: root + mode: "0440" + always: + - name: Remove the local file for the sudo rules + ansible.builtin.file: + path: "{{ ssh_ca_sudoers_local.path }}" + state: absent + delegate_to: localhost + become: false + check_mode: false + when: ssh_ca_sudoers_local.path is defined + +- name: Create the sshd drop-in directory + ansible.builtin.file: + path: /etc/ssh/sshd_config.d + state: directory + owner: root + group: root + mode: "0755" + +# DSM's sshd_config has no Include. sshd keeps the first value it reads, so +# the Include goes first for the drop-ins to win. +- name: Include the sshd drop-in directory + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + line: Include /etc/ssh/sshd_config.d/*.conf + insertbefore: BOF + validate: sshd -t -f %s # A broken drop-in only bites on the next sshd restart, so it is tested here # and removed again if the full config does not parse. diff --git a/ansible/roles/ssh-ca/tasks/report.yml b/ansible/roles/ssh-ca/tasks/report.yml index 1edd3c8f..ceff9b87 100644 --- a/ansible/roles/ssh-ca/tasks/report.yml +++ b/ansible/roles/ssh-ca/tasks/report.yml @@ -3,11 +3,10 @@ # [ssh_keys_managed] have this reduced to ssh_admin_authorized_keys. - name: Find the admin logins' authorized_keys ansible.builtin.stat: - path: "{{ item.ansible_facts.getent_passwd[item.item][4] }}/.ssh/authorized_keys" - loop: "{{ ssh_ca_owner_lookup.results }}" + path: "{{ item[5] }}/.ssh/authorized_keys" + loop: "{{ ssh_ca_admin_logins }}" loop_control: - label: "{{ item.item }}" - when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none + label: "{{ item[0] }}" register: ssh_ca_keyfiles - name: Fingerprint the admin logins' authorized keys diff --git a/ansible/site.yml b/ansible/site.yml index 6637a525..3a7c3a85 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -8,6 +8,8 @@ # One-off actions are not state and stay separate: plays/update-os.yml, # plays/update-dist-upgrade.yml, plays/rotate-netbird-secrets.yml. +- name: Synology Python + ansible.builtin.import_playbook: plays/configure-synology-python.yml - name: Base system ansible.builtin.import_playbook: plays/common.yml - name: VM time sync -- 2.54.0 From c32dd3bdfd268e2a1fc77fd29b9a2033cb75efe4 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 29 Oct 2026 11:20:16 +0100 Subject: [PATCH 2/8] feat(ansible): claude-bot has full sudo on nas-2 On DSM claude-bot is in administrators, whose sudo rule asks for a password nobody knows. ssh_ca_claude_sudo_all (default false) adds a passwordless rule; synology_nas sets it. Co-Authored-By: Claude Opus 5.5 --- ansible/inventory/group_vars/synology_nas/vars.yml | 3 +++ ansible/roles/ssh-ca/defaults/main.yml | 3 +++ ansible/roles/ssh-ca/templates/sudoers-claude.j2 | 3 +++ 3 files changed, 9 insertions(+) diff --git a/ansible/inventory/group_vars/synology_nas/vars.yml b/ansible/inventory/group_vars/synology_nas/vars.yml index 889ceb81..c2fc7b5d 100644 --- a/ansible/inventory/group_vars/synology_nas/vars.yml +++ b/ansible/inventory/group_vars/synology_nas/vars.yml @@ -3,3 +3,6 @@ ansible_python_interpreter: /var/packages/Python3.9/target/usr/bin/python3.9 # DSM offers no sftp/scp to ladmin. ansible_ssh_transfer_method: piped + +# claude-bot is a DSM administrator here; its password is unknown. +ssh_ca_claude_sudo_all: true diff --git a/ansible/roles/ssh-ca/defaults/main.yml b/ansible/roles/ssh-ca/defaults/main.yml index 5b1e508d..3e8e41c0 100644 --- a/ansible/roles/ssh-ca/defaults/main.yml +++ b/ansible/roles/ssh-ca/defaults/main.yml @@ -16,3 +16,6 @@ ssh_ca_claude_principal: claude-bot # Units claude-bot may restart/reload via sudo on this host, e.g. [netbird]. ssh_ca_claude_sudo_units: [] + +# Full passwordless sudo for claude-bot on this host. +ssh_ca_claude_sudo_all: false diff --git a/ansible/roles/ssh-ca/templates/sudoers-claude.j2 b/ansible/roles/ssh-ca/templates/sudoers-claude.j2 index 59878c8f..e28db199 100644 --- a/ansible/roles/ssh-ca/templates/sudoers-claude.j2 +++ b/ansible/roles/ssh-ca/templates/sudoers-claude.j2 @@ -10,3 +10,6 @@ {% for unit in ssh_ca_claude_sudo_units %} {{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/systemctl restart {{ unit }}, /usr/bin/systemctl reload {{ unit }} {% endfor %} +{% if ssh_ca_claude_sudo_all %} +{{ ssh_ca_claude_user }} ALL=(ALL) NOPASSWD: ALL +{% endif %} -- 2.54.0 From 2c22f6653963eecedcffbe26d25f90e0cc99e8c6 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 29 Oct 2026 11:27:33 +0100 Subject: [PATCH 3/8] feat(ansible): nas-1 back in the inventory nas-1 is powered on again. It joins [synology_nas] and [ssh_ca] like nas-2; the roles and plays dropped with it in 9c9e45d1 stay dropped. Co-Authored-By: Claude Opus 5.5 --- ansible/inventory/pve_hosts.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index 8482fb2a..ae360d56 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -8,8 +8,6 @@ # pve_cluster (jupiter/neptune/saturn) run Talos on bare metal; no SSH at all # authentik_db / authentik_app authentik runs in-cluster (CNPG) # grafana runs in-cluster -# synology_nas (nas-1) powered off; Garage and the PBS client -# it hosted are gone with it # Their hosts no longer resolve, except the three that resolve to Talos nodes. [netbird_server] @@ -25,9 +23,10 @@ proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root [edge_host] edge-1.iot.renner.uno ansible_user=root -# Off-site Synology DS1522+ (DSM). ladmin is the only admin login; become -# needs its password. +# Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site. ladmin +# is the admin login; become needs its password. [synology_nas] +nas-1.renner.uno ansible_user=ladmin nas-2.renner.uno ansible_user=ladmin # Capability groups. Membership states which hardware a host physically has, @@ -75,6 +74,7 @@ proxy-1.renner.uno proxy-2.renner.uno netbird.renner.uno edge-1.iot.renner.uno +nas-1.renner.uno nas-2.renner.uno # Hosts whose admin logins accept only ssh_admin_authorized_keys (break-glass); -- 2.54.0 From 3b1bd4cd2eddcf8daed685c2ed7c3efeb6d55067 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 29 Oct 2026 11:29:59 +0100 Subject: [PATCH 4/8] feat(ansible): Synology hosts are reached as root With the SSH CA in place, root accepts the owner certificate on DSM (PermitRootLogin without-password), so runs need no become password, as on every other host. Co-Authored-By: Claude Opus 5.5 --- ansible/inventory/group_vars/synology_nas/vars.yml | 2 +- ansible/inventory/pve_hosts.yml | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ansible/inventory/group_vars/synology_nas/vars.yml b/ansible/inventory/group_vars/synology_nas/vars.yml index c2fc7b5d..da1aa919 100644 --- a/ansible/inventory/group_vars/synology_nas/vars.yml +++ b/ansible/inventory/group_vars/synology_nas/vars.yml @@ -1,7 +1,7 @@ --- # DSM's own python3 is 3.8; plays/configure-synology-python.yml installs this. ansible_python_interpreter: /var/packages/Python3.9/target/usr/bin/python3.9 -# DSM offers no sftp/scp to ladmin. +# DSM offers no sftp/scp. ansible_ssh_transfer_method: piped # claude-bot is a DSM administrator here; its password is unknown. diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index ae360d56..0c67e913 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -23,11 +23,10 @@ proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root [edge_host] edge-1.iot.renner.uno ansible_user=root -# Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site. ladmin -# is the admin login; become needs its password. +# Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site. [synology_nas] -nas-1.renner.uno ansible_user=ladmin -nas-2.renner.uno ansible_user=ladmin +nas-1.renner.uno ansible_user=root +nas-2.renner.uno ansible_user=root # Capability groups. Membership states which hardware a host physically has, # and each service playbook targets the group for its own concern. No flags and -- 2.54.0 From 6dede6c422c7cd8c9541b7451b22607464d9629e Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 29 Oct 2026 11:40:38 +0100 Subject: [PATCH 5/8] fix(ansible): reach nas-1 as ladmin until it trusts the SSH CA root on nas-1 accepts no owner key or certificate yet, so the first run has to go through ladmin with its become password. Co-Authored-By: Claude Opus 5.5 --- ansible/inventory/pve_hosts.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index 0c67e913..3345bf8e 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -24,8 +24,10 @@ proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root edge-1.iot.renner.uno ansible_user=root # Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site. +# nas-1 is reached as ladmin (become needs its password) until root there +# trusts the SSH CA. [synology_nas] -nas-1.renner.uno ansible_user=root +nas-1.renner.uno ansible_user=ladmin nas-2.renner.uno ansible_user=root # Capability groups. Membership states which hardware a host physically has, -- 2.54.0 From 4d97dd6e1fa4f30cd7fd8f9cead80a59ecf5d597 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 29 Oct 2026 11:54:25 +0100 Subject: [PATCH 6/8] feat(ansible): reach nas-1 as root nas-1 trusts the SSH CA now; root accepts the owner certificate there, as on nas-2. Co-Authored-By: Claude Opus 5.5 --- ansible/inventory/pve_hosts.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index 3345bf8e..0c67e913 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -24,10 +24,8 @@ proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root edge-1.iot.renner.uno ansible_user=root # Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site. -# nas-1 is reached as ladmin (become needs its password) until root there -# trusts the SSH CA. [synology_nas] -nas-1.renner.uno ansible_user=ladmin +nas-1.renner.uno ansible_user=root nas-2.renner.uno ansible_user=root # Capability groups. Membership states which hardware a host physically has, -- 2.54.0 From 41cadcb3ac7e562530ab4cc55b8375ba4f5f66f2 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 29 Oct 2026 12:04:38 +0100 Subject: [PATCH 7/8] fix(ansible): site.yml runs without warnings or no-op changes - ssh-ca reloads sshd only when the CA key, the Include or the drop-in changed; principals and revoked keys are read per login. - On DSM the sudo rules are checked with visudo -c -f - on the controller instead of a temporary file, which reported three changes per run. - Facts are read through ansible_facts (INJECT_FACTS_AS_VARS deprecation) in common, qemu-guest-agent and keepalived-ntp. - common masks systemd-timesyncd on guests only; a KVM host such as a NAS running VMM has no such unit. - netbird-client uses deb822_repository (apt_repository deprecation) and removes the old one-line source. Co-Authored-By: Claude Opus 5.5 --- ansible/roles/common/tasks/main.yml | 11 ++--- .../templates/keepalived.conf.j2 | 2 +- ansible/roles/netbird-client/tasks/main.yml | 19 ++++++-- ansible/roles/qemu-guest-agent/tasks/main.yml | 8 ++-- ansible/roles/ssh-ca/tasks/main.yml | 46 ++++++++----------- 5 files changed, 43 insertions(+), 43 deletions(-) diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml index f34b2387..c5e02472 100644 --- a/ansible/roles/common/tasks/main.yml +++ b/ansible/roles/common/tasks/main.yml @@ -8,12 +8,11 @@ lineinfile: path: /etc/hosts regexp: '^127\.0\.1\.1\s+{{ inventory_hostname }}' - line: "{{ ansible_default_ipv4.address }} {{ inventory_hostname }} {{ inventory_hostname_short | default(inventory_hostname.split('.')[0]) }}" + line: "{{ ansible_facts['default_ipv4']['address'] }} {{ inventory_hostname }} {{ inventory_hostname_short | default(inventory_hostname.split('.')[0]) }}" state: present when: - - ansible_virtualization_type is defined - - ansible_virtualization_type == 'lxc' - - ansible_default_ipv4.address is defined + - ansible_facts['virtualization_type'] == 'lxc' + - ansible_facts['default_ipv4']['address'] is defined # ------------------------------------------------------- # 1. Install Common Packages @@ -64,8 +63,8 @@ enabled: no masked: yes when: - - ansible_virtualization_type is defined - - ansible_virtualization_type in ['kvm', 'lxc'] + - ansible_facts['virtualization_role'] == 'guest' + - ansible_facts['virtualization_type'] in ['kvm', 'lxc'] ignore_errors: yes # ------------------------------------------------------- diff --git a/ansible/roles/keepalived-ntp/templates/keepalived.conf.j2 b/ansible/roles/keepalived-ntp/templates/keepalived.conf.j2 index 18e4f27a..a1484f60 100644 --- a/ansible/roles/keepalived-ntp/templates/keepalived.conf.j2 +++ b/ansible/roles/keepalived-ntp/templates/keepalived.conf.j2 @@ -2,7 +2,7 @@ # Managed by Ansible - Do not edit manually global_defs { - router_id {{ ansible_hostname }}_ntp + router_id {{ ansible_facts['hostname'] }}_ntp enable_script_security script_user root } diff --git a/ansible/roles/netbird-client/tasks/main.yml b/ansible/roles/netbird-client/tasks/main.yml index 040dd923..9c4d5625 100644 --- a/ansible/roles/netbird-client/tasks/main.yml +++ b/ansible/roles/netbird-client/tasks/main.yml @@ -11,6 +11,8 @@ - curl - ca-certificates - gnupg + # deb822_repository + - python3-debian state: present update_cache: true @@ -21,10 +23,19 @@ creates: /usr/share/keyrings/netbird-archive-keyring.gpg - name: Add NetBird repository - ansible.builtin.apt_repository: - repo: "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main" - state: present - filename: netbird + ansible.builtin.deb822_repository: + name: netbird + types: deb + uris: https://pkgs.netbird.io/debian + suites: stable + components: main + signed_by: /usr/share/keyrings/netbird-archive-keyring.gpg + + # A one-line netbird.list beside netbird.sources lists the repository twice. + - name: Remove the one-line NetBird repository + ansible.builtin.file: + path: /etc/apt/sources.list.d/netbird.list + state: absent - name: Install NetBird client ansible.builtin.apt: diff --git a/ansible/roles/qemu-guest-agent/tasks/main.yml b/ansible/roles/qemu-guest-agent/tasks/main.yml index de909190..eb62cc53 100644 --- a/ansible/roles/qemu-guest-agent/tasks/main.yml +++ b/ansible/roles/qemu-guest-agent/tasks/main.yml @@ -6,15 +6,15 @@ apt: name: qemu-guest-agent state: present - when: - - ansible_os_family is defined and ansible_os_family == 'Debian' + when: + - ansible_facts['os_family'] == 'Debian' - name: Install QEMU Guest Agent (RHEL/Rocky VMs) dnf: name: qemu-guest-agent state: present - when: - - ansible_os_family is defined and ansible_os_family == 'RedHat' + when: + - ansible_facts['os_family'] == 'RedHat' - name: Ensure qemu-guest-agent is started and enabled service: diff --git a/ansible/roles/ssh-ca/tasks/main.yml b/ansible/roles/ssh-ca/tasks/main.yml index 905b8096..b20e1556 100644 --- a/ansible/roles/ssh-ca/tasks/main.yml +++ b/ansible/roles/ssh-ca/tasks/main.yml @@ -23,6 +23,7 @@ owner: root group: root mode: "0644" + register: ssh_ca_trusted_file # sshd refuses all public key logins when RevokedKeys is set but unreadable, # so the file is always deployed, even when empty. @@ -152,44 +153,28 @@ validate: visudo -cf %s when: not ssh_ca_synouser.stat.exists -# DSM has no visudo, so the rules are rendered and checked on the controller. +# DSM has no visudo, so the rules are checked on the controller. - name: Install the Claude sudo rules on DSM when: ssh_ca_synouser.stat.exists + vars: + ssh_ca_sudoers: "{{ lookup('ansible.builtin.template', 'sudoers-claude.j2') | trim }}" block: - - name: Create a local file for the sudo rules - ansible.builtin.tempfile: - suffix: .sudoers - delegate_to: localhost - become: false - check_mode: false - register: ssh_ca_sudoers_local - - - name: Render and check the sudo rules on the controller - ansible.builtin.template: - src: sudoers-claude.j2 - dest: "{{ ssh_ca_sudoers_local.path }}" - mode: "0600" - validate: visudo -cf %s + - name: Check the sudo rules on the controller + ansible.builtin.command: + cmd: visudo -c -f - + stdin: "{{ ssh_ca_sudoers }}" delegate_to: localhost become: false + changed_when: false check_mode: false - name: Install the checked sudo rules ansible.builtin.copy: - src: "{{ ssh_ca_sudoers_local.path }}" + content: "{{ ssh_ca_sudoers }}\n" dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}" owner: root group: root mode: "0440" - always: - - name: Remove the local file for the sudo rules - ansible.builtin.file: - path: "{{ ssh_ca_sudoers_local.path }}" - state: absent - delegate_to: localhost - become: false - check_mode: false - when: ssh_ca_sudoers_local.path is defined - name: Create the sshd drop-in directory ansible.builtin.file: @@ -207,6 +192,7 @@ line: Include /etc/ssh/sshd_config.d/*.conf insertbefore: BOF validate: sshd -t -f %s + register: ssh_ca_include_line # A broken drop-in only bites on the next sshd restart, so it is tested here # and removed again if the full config does not parse. @@ -219,6 +205,7 @@ owner: root group: root mode: "0644" + register: ssh_ca_dropin_file - name: Test the full sshd config ansible.builtin.command: sshd -t @@ -255,10 +242,11 @@ fail_msg: "sshd -T does not show the SSH CA settings; another config line takes precedence" # The unit is sshd.service on Arch and RedHat, ssh.service on Debian/Ubuntu, -# where sshd.service is an alias that resolves to it. sshd -T only parses the -# files, so the running daemon is reloaded on every run; a reload keeps open +# where sshd.service is an alias that resolves to it. The running daemon is +# reloaded when a file it reads at start changed; a reload keeps open # sessions. None runs when sshd is socket activated and stopped, or per # connection (ssh-mesh-only role); the next start reads the config. +# Principals and revoked keys are read per login and need no reload. # systemctl directly: service_facts took 3-12 s per host. - name: Find the running sshd unit ansible.builtin.command: systemctl list-units --state=running --plain --no-legend --type=service sshd.service ssh.service @@ -280,7 +268,9 @@ ansible.builtin.systemd: name: "{{ ssh_ca_sshd_units | first }}" state: reloaded - when: ssh_ca_sshd_units | length == 1 + when: + - ssh_ca_sshd_units | length == 1 + - ssh_ca_trusted_file is changed or ssh_ca_include_line is changed or ssh_ca_dropin_file is changed - name: Report the admin logins' authorized keys ansible.builtin.include_tasks: report.yml -- 2.54.0 From 9cb8e65d859e8abd21bd20ed923c095b0f4ff066 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 29 Oct 2026 12:45:57 +0100 Subject: [PATCH 8/8] fix(crowdsec-edge-bouncer): cache the release outside /tmp The archive was fetched into the controller's /tmp, so every run after a /tmp cleanup downloaded it again and reported a change. It now lives in ~/.cache/ansible/crowdsec, like the netbird-client download. Co-Authored-By: Claude Opus 5.5 --- .../roles/crowdsec-edge-bouncer/defaults/main.yml | 2 ++ ansible/roles/crowdsec-edge-bouncer/tasks/main.yml | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ansible/roles/crowdsec-edge-bouncer/defaults/main.yml b/ansible/roles/crowdsec-edge-bouncer/defaults/main.yml index 1eb83cce..783f3d77 100644 --- a/ansible/roles/crowdsec-edge-bouncer/defaults/main.yml +++ b/ansible/roles/crowdsec-edge-bouncer/defaults/main.yml @@ -15,6 +15,8 @@ # amd64 asset for exactly this tag. crowdsec_bouncer_version: v0.0.36 crowdsec_bouncer_sha256: f86e4b72693549d99f40a9402abefb894108f047a3fbe6e72fada25ee17ce88b +# Controller cache for the release archive; it outlives /tmp cleanups. +crowdsec_bouncer_download_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/.cache/ansible/crowdsec" # The LAPI over the public path these hosts already serve, see # kubernetes/infrastructure/crowdsec/lapi-ingress.yaml. The proxies cannot diff --git a/ansible/roles/crowdsec-edge-bouncer/tasks/main.yml b/ansible/roles/crowdsec-edge-bouncer/tasks/main.yml index ba7cec68..82de4e8b 100644 --- a/ansible/roles/crowdsec-edge-bouncer/tasks/main.yml +++ b/ansible/roles/crowdsec-edge-bouncer/tasks/main.yml @@ -32,13 +32,23 @@ # The controller has v4, so it does the fetching. The checksum is verified # there, before anything is copied to a host. Also in check mode: the fetch # only writes on the controller, and the copy below needs the file. +- name: Create the controller download directory + ansible.builtin.file: + path: "{{ crowdsec_bouncer_download_dir }}" + state: directory + mode: "0755" + delegate_to: localhost + become: false + run_once: true + check_mode: false + - name: Fetch the pinned bouncer release on the controller check_mode: false ansible.builtin.get_url: url: >- https://github.com/crowdsecurity/cs-firewall-bouncer/releases/download/{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer-linux-amd64.tgz - dest: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz + dest: "{{ crowdsec_bouncer_download_dir }}/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz" checksum: sha256:{{ crowdsec_bouncer_sha256 }} mode: "0644" delegate_to: localhost @@ -53,7 +63,7 @@ # host. - name: Copy the bouncer archive to the host ansible.builtin.copy: - src: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz + src: "{{ crowdsec_bouncer_download_dir }}/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz" dest: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz mode: "0644" -- 2.54.0