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..da1aa919 --- /dev/null +++ b/ansible/inventory/group_vars/synology_nas/vars.yml @@ -0,0 +1,8 @@ +--- +# 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. +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/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index 8a8a0fd0..0c67e913 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,6 +23,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 +# Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site. +[synology_nas] +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 # no `when:` on the roles -- moving a stick to another box is a one-line change @@ -70,6 +73,8 @@ 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); # 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/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/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" 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/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/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..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. @@ -34,6 +35,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 +64,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 +127,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 +151,48 @@ group: root mode: "0440" validate: visudo -cf %s + when: not ssh_ca_synouser.stat.exists + +# 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: 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: + content: "{{ ssh_ca_sudoers }}\n" + dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}" + owner: root + group: root + mode: "0440" + +- 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 + 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. @@ -100,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 @@ -136,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 @@ -161,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 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/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 %} 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