diff --git a/AGENTS.md b/AGENTS.md index 49bf2ad..f4798c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,8 +158,12 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i - `aegis` is a remote Fedora CoreOS Raspberry Pi 4 node. Bootstrap it once with `ansible/bootstrap/aegis.bu`; the remaining configuration is applied by `profile_aegis` over SSH. - Fedora CoreOS is immutable. Do not add it to mutable Fedora package or shared dotfile roles. -- `profile_aegis` owns rootful Podman Quadlets, persistent container state under `/var/lib`, and the - Podman auto-update timer. Keep Apple IDs and other credentials in Vault and use `no_log` for their - rendering. +- `profile_aegis` owns rootful Podman Quadlets, persistent container state under `/var/lib`, the + Podman auto-update timer, LAN-restricted firewalld rules, and SSH hardening. Keep + `aegis_lan_subnet` and `aegis_adguard_web_port` host-specific; SSH permits only the declared + key-authenticated users, never root or password authentication. Keep Apple IDs and other + credentials in Vault and use `no_log` for their rendering. +- The initial AdGuard Home wizard uses `aegis_adguard_web_port: 3000`. After selecting a different + UI port, update that host variable and rerun the playbook to close the previous port. - iCloudPD requires post-deployment interactive MFA initialization; its cookie/configuration state is persisted in `/var/lib/icloudpd/config`. diff --git a/README.md b/README.md index e4b8e6a..fa50c45 100644 --- a/README.md +++ b/README.md @@ -116,18 +116,30 @@ ansible-playbook ansible/site.yml --limit prometheus \ ## Aegis -`aegis` is a Raspberry Pi 4 running Fedora CoreOS. Provision it once with -`ansible/bootstrap/aegis.bu`, after replacing the SSH public-key placeholder: +`aegis` is a Raspberry Pi 4 running Fedora CoreOS. Generate Ignition from +`ansible/bootstrap/aegis.bu` with the included Podman/Butane helper, then write the SD card with +`arm-image-installer`: ```bash -butane --strict --pretty --output aegis.ign ansible/bootstrap/aegis.bu +ansible/bootstrap/generate-aegis-ign.sh --write IMAGE DEVICE ``` -The controller then manages it remotely as `core@aegis`; unlike local desktop profiles, Aegis is +The controller manages it remotely as `pi@aegis`; unlike local desktop profiles, Aegis is intentionally an SSH inventory target. `profile_aegis` manages rootful Podman Quadlets for AdGuard -Home and iCloudPD, persistent data under `/var/lib`, the Podman auto-update timer, and -`wake-ikaros`. Define `vault_aegis_icloudpd_apple_id` in Vault before applying it. iCloudPD still -requires interactive MFA initialization after its first deployment. +Home and iCloudPD, persistent data under `/var/lib`, the Podman auto-update timer, LAN-restricted +firewalld rules, SSH key-only access for `pi`, and `wake-ikaros`. Set the host-local +`aegis_lan_subnet` and `aegis_adguard_web_port` values before applying it. The initial AdGuard Home +wizard uses port `3000`; after choosing another web port, update `aegis_adguard_web_port` and rerun +the playbook so the firewall only permits the selected port. Define +`vault_aegis_icloudpd_apple_id` in Vault before applying it. iCloudPD still requires interactive MFA +initialization after its first deployment. + +Validate the profile before deployment: + +```bash +ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \ +ansible-playbook ansible/site.yml --limit aegis --check --diff --ask-become-pass +``` ## NAS diff --git a/ansible/bootstrap/aegis.bu b/ansible/bootstrap/aegis.bu index 75c3c4b..b69826d 100644 --- a/ansible/bootstrap/aegis.bu +++ b/ansible/bootstrap/aegis.bu @@ -50,14 +50,6 @@ storage: contents: inline: | net.ipv4.ip_forward = 1 - - path: /etc/hosts - mode: 0644 - overwrite: true - contents: - inline: | - 127.0.0.1 localhost - ::1 localhost - 192.168.0.1 aegis links: - path: /etc/localtime target: ../usr/share/zoneinfo/Europe/Rome diff --git a/ansible/inventory/host_vars/aegis.yml b/ansible/inventory/host_vars/aegis.yml index 2232f68..725df5d 100644 --- a/ansible/inventory/host_vars/aegis.yml +++ b/ansible/inventory/host_vars/aegis.yml @@ -5,4 +5,7 @@ ansible_user: pi ansible_become: true ansible_python_interpreter: /usr/bin/python3 +aegis_lan_subnet: 192.168.178.0/24 +aegis_adguard_web_port: 3000 + aegis_icloudpd_apple_id: "{{ vault_aegis_icloudpd_apple_id | default('') }}" diff --git a/ansible/roles/profile_aegis/defaults/main.yml b/ansible/roles/profile_aegis/defaults/main.yml index 34a7525..8c9257f 100644 --- a/ansible/roles/profile_aegis/defaults/main.yml +++ b/ansible/roles/profile_aegis/defaults/main.yml @@ -7,3 +7,9 @@ aegis_icloudpd_synchronisation_interval: 86400 aegis_icloudpd_apple_id: "" aegis_ikaros_mac_address: aa:bb:cc:dd:ee:ff aegis_wol_port: 9 + +aegis_lan_subnet: CHANGEME_LAN_SUBNET +aegis_firewalld_zone: public +aegis_adguard_web_port: 3000 +aegis_ssh_allowed_users: + - pi diff --git a/ansible/roles/profile_aegis/handlers/main.yml b/ansible/roles/profile_aegis/handlers/main.yml index 304f675..09db386 100644 --- a/ansible/roles/profile_aegis/handlers/main.yml +++ b/ansible/roles/profile_aegis/handlers/main.yml @@ -1,4 +1,9 @@ --- +- name: Reload Aegis SSH + ansible.builtin.systemd: + name: sshd.service + state: reloaded + - name: Restart Aegis Quadlet services ansible.builtin.systemd: name: "{{ item }}" diff --git a/ansible/roles/profile_aegis/tasks/main.yml b/ansible/roles/profile_aegis/tasks/main.yml index a1915e5..04d8845 100644 --- a/ansible/roles/profile_aegis/tasks/main.yml +++ b/ansible/roles/profile_aegis/tasks/main.yml @@ -7,6 +7,17 @@ fail_msg: Define vault_aegis_icloudpd_apple_id before applying the Aegis profile. no_log: true +- name: Require completed Aegis network placeholders + tags: [aegis, firewall, services] + ansible.builtin.assert: + that: + - aegis_lan_subnet != 'CHANGEME_LAN_SUBNET' + - aegis_firewalld_zone | length > 0 + - aegis_adguard_web_port | int > 0 + - aegis_adguard_web_port | int < 65536 + - aegis_ssh_allowed_users | length > 0 + fail_msg: Define the Aegis LAN subnet, firewalld zone, AdGuard web port, and SSH users. + - name: Set Aegis hostname tags: [aegis, services] ansible.builtin.hostname: @@ -53,6 +64,146 @@ no_log: "{{ item.dest == 'icloudpd.container' }}" notify: Restart Aegis Quadlet services +- name: Enable Aegis firewalld + tags: [aegis, firewall, services] + ansible.builtin.systemd: + name: firewalld.service + enabled: true + state: started + +- name: Render Aegis AdGuard web firewalld service + tags: [aegis, firewall] + ansible.builtin.template: + src: aegis-adguard-web.xml.j2 + dest: /etc/firewalld/services/aegis-adguard-web.xml + owner: root + group: root + mode: "0644" + +- name: Reload firewalld custom services + tags: [aegis, firewall] + ansible.builtin.command: + argv: + - firewall-cmd + - --reload + changed_when: false + when: not ansible_check_mode + +- name: Remove unrestricted Aegis services from firewalld zone + tags: [aegis, firewall] + ansible.posix.firewalld: + service: "{{ item }}" + permanent: true + immediate: true + state: disabled + zone: "{{ aegis_firewalld_zone }}" + loop: + - ssh + - dns + - http + - aegis-adguard-web + loop_control: + label: "{{ item }}" + +- name: Allow Aegis services from the LAN only + tags: [aegis, firewall] + ansible.posix.firewalld: + rich_rule: "{{ item }}" + permanent: true + immediate: true + state: enabled + zone: "{{ aegis_firewalld_zone }}" + loop: + - 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="ssh" accept' + - 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="dns" accept' + - 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="aegis-adguard-web" accept' + loop_control: + label: "{{ item }}" + +- name: Check the standard Aegis SSH authorized keys file + tags: [aegis, ssh, services] + ansible.builtin.stat: + path: "{{ ansible_user_dir }}/.ssh/authorized_keys" + register: aegis_authorized_keys + +- name: Find Aegis SSH authorized key fragments + tags: [aegis, ssh, services] + ansible.builtin.find: + paths: "{{ ansible_user_dir }}/.ssh/authorized_keys.d" + file_type: file + recurse: false + register: aegis_authorized_key_fragments + +- name: Require an Aegis SSH authorized key before hardening + tags: [aegis, ssh, services] + ansible.builtin.assert: + that: + - >- + (aegis_authorized_keys.stat.exists and aegis_authorized_keys.stat.size | int > 0) + or aegis_authorized_key_fragments.matched | int > 0 + fail_msg: Add a public key for the Ansible SSH user before disabling password authentication. + +- name: Ensure Aegis SSH configuration drop-in directory exists + tags: [aegis, ssh, services] + ansible.builtin.file: + path: /etc/ssh/sshd_config.d + state: directory + owner: root + group: root + mode: "0755" + +- name: Ensure Aegis SSH drop-ins are loaded before other settings + tags: [aegis, ssh, services] + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: '^\s*Include\s+/etc/ssh/sshd_config\.d/\*\.conf\s*$' + line: Include /etc/ssh/sshd_config.d/*.conf + insertbefore: BOF + state: present + validate: "sshd -t -f %s" + +- name: Render Aegis SSH hardening drop-in + tags: [aegis, ssh, services] + ansible.builtin.template: + src: 00-aegis-hardening.conf.j2 + dest: /etc/ssh/sshd_config.d/00-aegis-hardening.conf + owner: root + group: root + mode: "0600" + notify: Reload Aegis SSH + +- name: Validate Aegis SSH configuration + tags: [aegis, ssh, services] + ansible.builtin.command: + argv: + - sshd + - -t + changed_when: false + when: not ansible_check_mode + +- name: Read effective Aegis SSH daemon configuration + tags: [aegis, ssh, services] + ansible.builtin.command: + argv: + - sshd + - -T + - -C + - "user={{ ansible_user }},host={{ aegis_hostname }},addr=127.0.0.1" + register: aegis_sshd_effective_configuration + changed_when: false + when: not ansible_check_mode + +- name: Verify effective Aegis SSH hardening + tags: [aegis, ssh, services] + ansible.builtin.assert: + that: + - "'permitrootlogin no' in aegis_sshd_effective_configuration.stdout_lines" + - "'pubkeyauthentication yes' in aegis_sshd_effective_configuration.stdout_lines" + - "'passwordauthentication no' in aegis_sshd_effective_configuration.stdout_lines" + - "'kbdinteractiveauthentication no' in aegis_sshd_effective_configuration.stdout_lines" + - "'allowusers ' + (aegis_ssh_allowed_users | join(' ')) in aegis_sshd_effective_configuration.stdout_lines" + when: not ansible_check_mode + - name: Install Wake-on-LAN helper for Ikaros tags: [aegis, wol] ansible.builtin.template: diff --git a/ansible/roles/profile_aegis/templates/00-aegis-hardening.conf.j2 b/ansible/roles/profile_aegis/templates/00-aegis-hardening.conf.j2 new file mode 100644 index 0000000..6f4b432 --- /dev/null +++ b/ansible/roles/profile_aegis/templates/00-aegis-hardening.conf.j2 @@ -0,0 +1,6 @@ +# Managed by Ansible. Do not edit manually. +PermitRootLogin no +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +AllowUsers {{ aegis_ssh_allowed_users | join(' ') }} diff --git a/ansible/roles/profile_aegis/templates/aegis-adguard-web.xml.j2 b/ansible/roles/profile_aegis/templates/aegis-adguard-web.xml.j2 new file mode 100644 index 0000000..4c52d30 --- /dev/null +++ b/ansible/roles/profile_aegis/templates/aegis-adguard-web.xml.j2 @@ -0,0 +1,6 @@ + + + Aegis AdGuard Home web interface + AdGuard Home administrative web interface on Aegis. + +