feat(ansible): nas-1 and nas-2 trust the SSH user CA #1520
@@ -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
|
||||
@@ -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].
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
# -------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] }}"
|
||||
|
||||
@@ -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 "<n>:[<name>]".
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user