diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e7eeb9e..5138376 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -74,7 +74,9 @@ "options": [ "common", "git", + "nftables", "podman", + "router", "scripts", "shell", "ssh", diff --git a/README.md b/README.md index 53468bb..12de007 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,12 @@ SSH. | Tag | Role | Configuration | | --- | --- | --- | -| `common` | `common` | Base development packages and Node.js 20 | -| `systemd` | `systemd` | `systemd-resolved` and NetworkManager DNS integration | +| `common` | `common` | Base development packages and Node.js 24 LTS | +| `systemd` | `systemd` | `systemd-resolved`, mDNS, NSS, and NetworkManager DNS integration | | `ssh` | `ssh_hardening` | OpenSSH server installation and ssh-audit-based cryptographic hardening | | `podman` | `podman` | Rootless Podman and Podman Compose configuration | +| `nftables` | `nftables` | Default input firewall with SSH, ICMP, and mDNS access | +| `router` | `router` | Optional IPv4 router with DHCP, forwarding, and NAT | | `shell` | `shell_config` | Fish, Starship, direnv, fzf, pyenv, fonts, and Konsole profile | | `scripts` | `system_scripts` | Helper scripts in `/usr/local/bin` | | `vscode` | `vscode` | VS Code repository, application, settings, and extensions | @@ -24,6 +26,94 @@ overridden with `ssh_hardening_*` variables. Ensure key-based access works before deploying the role remotely; it does not configure a firewall or Fail2Ban. +## Testing DNS and mDNS + +After deployment, verify that `systemd-resolved` is active and Avahi is not +running: + +```bash +systemctl is-active systemd-resolved.service +systemctl is-active avahi-daemon.service +``` + +The first command must return `active`. The second must return `inactive` or +report that the unit does not exist. + +Check the active network link, replacing `wlp2s0` with the appropriate +interface name: + +```bash +resolvectl status wlp2s0 +``` + +The output should list the DHCP-provided DNS server (for example, +`192.168.1.1`), the `fritz.box` search domain, and `+mDNS` under `Protocols`. + +From another device on the same Layer-2 network segment, resolve the +workstation hostname: + +```bash +resolvectl query workstation.local +``` + +Replace `workstation` with the target's hostname. The command should return +the target's IPv4 and/or IPv6 address. To confirm that the input filter loaded +the mDNS rules, run on the target: + +```bash +sudo nft list chain inet filter input +``` + +The output must include UDP port `5353` rules for `224.0.0.251` (IPv4) and +`ff02::fb` (IPv6). + +To inspect the mDNS traffic directly, run the following command on the target +while resolving its `.local` hostname from another device: + +```bash +sudo tcpdump -ni wlp2s0 'udp port 5353' +``` + +Replace `wlp2s0` with the active network interface. The capture should show +UDP packets to `224.0.0.251.5353` (IPv4) and/or `ff02::fb.5353` (IPv6). +mDNS is link-local and does not cross routers; use unicast DNS for names in a +different IP subnet. + +## Router Configuration + +The `router` role is disabled by default to avoid changing a normal workstation +into a gateway. Enable it for the intended host in inventory variables and +configure the LAN interface that serves DHCP, the WAN interface used for NAT, +and the addresses announced to DHCP clients: + +```yaml +router_enabled: true +router_lan_interface: usbnet +router_lan_mac_address: "3c:18:a0:0f:72:63" +router_wan_interface: wlp1s0 +router_lan_address: 192.168.100.1/24 +router_lan_subnet: 192.168.100.0/24 +router_dhcp_gateway: 192.168.100.1 +router_dhcp_dns_servers: + - 192.168.100.1 +``` + +The `nftables` role is enabled by default. It configures a default-drop input +filter with access for established connections, loopback, ICMP/ICMPv6, SSH, and +mDNS. It writes `/etc/nftables.conf` with `flush ruleset` and includes +`/etc/nftables.d/*.nft`, creates `/etc/nftables.d/10-filter.nft`, and manages +the `nftables` service. To prevent conflicting input hooks, it disables +`firewalld` when that service is installed. + +The optional `router` role assigns the USB LAN adapter a stable interface name +using its MAC address in `/etc/systemd/network/10-router-lan.link`, configures +IPv4 forwarding, writes `/etc/systemd/network/10-router-lan.network`, and manages +`systemd-networkd`. It creates `/etc/nftables.d/20-router.nft`, which permits +LAN access to the router and adds forwarding plus source NAT. The DHCP pool and +lease durations are configurable through the `router_dhcp_*` variables in the +role defaults. A new `.link` file takes effect when the adapter is next +initialized; reboot the host or reconnect the adapter after deployment. + ## Requirements - Debian or Ubuntu system diff --git a/ansible/inventories/production/host_vars/localhost.yml b/ansible/inventories/production/host_vars/localhost.yml new file mode 100644 index 0000000..06776d7 --- /dev/null +++ b/ansible/inventories/production/host_vars/localhost.yml @@ -0,0 +1,3 @@ +--- +router_enabled: false +router_wan_interface: wlp2s0 diff --git a/ansible/playbooks/workstation.yml b/ansible/playbooks/workstation.yml index 1b81bc7..f8e3a6f 100644 --- a/ansible/playbooks/workstation.yml +++ b/ansible/playbooks/workstation.yml @@ -16,6 +16,10 @@ tags: [git] - role: podman tags: [podman] + - role: nftables + tags: [nftables, router] + - role: router + tags: [router] - role: shell_config tags: [shell] - role: ssh_hardening diff --git a/ansible/roles/common/handlers/main.yml b/ansible/roles/common/handlers/main.yml new file mode 100644 index 0000000..a4be4e8 --- /dev/null +++ b/ansible/roles/common/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Refresh NodeSource apt package cache + ansible.builtin.apt: + update_cache: true + become: true diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml index 9cab90b..c5d36df 100644 --- a/ansible/roles/common/tasks/main.yml +++ b/ansible/roles/common/tasks/main.yml @@ -26,12 +26,16 @@ ansible.builtin.deb822_repository: name: nodesource types: deb - uris: https://deb.nodesource.com/node_20.x + uris: https://deb.nodesource.com/node_24.x suites: nodistro components: main signed_by: /etc/apt/keyrings/nodesource.gpg state: present become: true + notify: Refresh NodeSource apt package cache + +- name: Refresh NodeSource apt package cache before package installation + ansible.builtin.meta: flush_handlers - name: Update apt package cache ansible.builtin.apt: diff --git a/ansible/roles/nftables/handlers/main.yml b/ansible/roles/nftables/handlers/main.yml new file mode 100644 index 0000000..2a96a70 --- /dev/null +++ b/ansible/roles/nftables/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart nftables + ansible.builtin.systemd: + name: nftables + state: restarted + become: true diff --git a/ansible/roles/nftables/tasks/main.yml b/ansible/roles/nftables/tasks/main.yml new file mode 100644 index 0000000..f720c32 --- /dev/null +++ b/ansible/roles/nftables/tasks/main.yml @@ -0,0 +1,65 @@ +- name: Install nftables + ansible.builtin.apt: + name: + - nftables + state: present + update_cache: true + become: true + +- name: Ensure nftables rules directory exists + ansible.builtin.file: + path: /etc/nftables.d + state: directory + owner: root + group: root + mode: "0755" + become: true + +- name: Configure nftables base configuration + ansible.builtin.copy: + content: | + #!/usr/sbin/nft -f + + flush ruleset + + include "/etc/nftables.d/*.nft" + dest: /etc/nftables.conf + owner: root + group: root + mode: "0644" + become: true + notify: Restart nftables + +- name: Configure nftables input filter + ansible.builtin.template: + src: 10-filter.nft.j2 + dest: /etc/nftables.d/10-filter.nft + owner: root + group: root + mode: "0644" + become: true + notify: Restart nftables + +- name: Validate assembled nftables configuration + ansible.builtin.command: + cmd: /usr/sbin/nft -c -f /etc/nftables.conf + become: true + changed_when: false + +- name: Gather service facts + ansible.builtin.service_facts: + +- name: Disable firewalld + ansible.builtin.systemd: + name: firewalld + enabled: false + state: stopped + become: true + when: ansible_facts.services['firewalld.service'] is defined + +- name: Enable nftables + ansible.builtin.systemd: + name: nftables + enabled: true + state: started + become: true diff --git a/ansible/roles/nftables/templates/10-filter.nft.j2 b/ansible/roles/nftables/templates/10-filter.nft.j2 new file mode 100644 index 0000000..e015a2a --- /dev/null +++ b/ansible/roles/nftables/templates/10-filter.nft.j2 @@ -0,0 +1,17 @@ +add table inet filter + +add chain inet filter input { + type filter hook input priority 0; policy drop; + + ct state invalid drop + ct state { established, related } accept + iifname "lo" accept + ip protocol icmp accept + ip6 nexthdr icmpv6 accept + meta nfproto ipv6 udp sport 547 udp dport 546 accept + # mDNS IPv4 multicast + ip daddr 224.0.0.251 udp dport 5353 accept + # mDNS IPv6 multicast + ip6 daddr ff02::fb udp dport 5353 accept + tcp dport 22 accept +} diff --git a/ansible/roles/router/defaults/main.yml b/ansible/roles/router/defaults/main.yml new file mode 100644 index 0000000..68529f5 --- /dev/null +++ b/ansible/roles/router/defaults/main.yml @@ -0,0 +1,24 @@ +--- +# Set to true for hosts that should route a LAN through a WAN connection. +router_enabled: false + +# The LAN interface runs the DHCP server; the WAN interface is used for NAT. +router_lan_interface: usbnet +router_lan_mac_address: "3c:18:a0:0f:72:63" +router_wan_interface: wlp1s0 + +# IPv4 configuration advertised to DHCP clients. +router_lan_address: 192.168.100.1/24 +router_lan_subnet: 192.168.100.0/24 +router_dhcp_gateway: 192.168.100.1 +router_dhcp_dns_servers: + - "{{ router_dhcp_gateway }}" +router_dhcp_pool_offset: 100 +router_dhcp_pool_size: 50 +router_dhcp_default_lease_time: 12h +router_dhcp_max_lease_time: 24h + +# Distribution packages that provide the RTL8153 USB Ethernet firmware patch. +router_rtl8153_firmware_packages: + Debian: firmware-realtek + Ubuntu: linux-firmware diff --git a/ansible/roles/router/handlers/main.yml b/ansible/roles/router/handlers/main.yml new file mode 100644 index 0000000..e1dd355 --- /dev/null +++ b/ansible/roles/router/handlers/main.yml @@ -0,0 +1,25 @@ +--- +- name: Restart NetworkManager + ansible.builtin.systemd: + name: NetworkManager + state: restarted + become: true + +- name: Restart systemd-networkd + ansible.builtin.systemd: + name: systemd-networkd + state: restarted + become: true + +- name: Restart systemd-resolved + ansible.builtin.systemd: + name: systemd-resolved + state: restarted + become: true + +- name: Apply sysctl settings + ansible.builtin.command: + cmd: sysctl --system + become: true + changed_when: false + listen: Apply sysctl settings diff --git a/ansible/roles/router/tasks/main.yml b/ansible/roles/router/tasks/main.yml new file mode 100644 index 0000000..3ebc907 --- /dev/null +++ b/ansible/roles/router/tasks/main.yml @@ -0,0 +1,143 @@ +--- +- name: Configure router + when: router_enabled | bool + block: + - name: Validate router configuration + ansible.builtin.assert: + that: + - router_lan_interface is string + - router_lan_interface is match('^[A-Za-z0-9_.:-]+$') + - router_lan_mac_address is match('^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$') + - router_wan_interface is string + - router_wan_interface is match('^[A-Za-z0-9_.:-]+$') + - router_lan_address is match('^[0-9A-Fa-f:.]+/[0-9]{1,3}$') + - router_lan_subnet is match('^[0-9A-Fa-f:.]+/[0-9]{1,3}$') + - router_dhcp_gateway is match('^[0-9A-Fa-f:.]+$') + - router_dhcp_dns_servers | length > 0 + - >- + router_dhcp_dns_servers | select('match', '^[0-9A-Fa-f:.]+$') | list | length + == router_dhcp_dns_servers | length + - router_dhcp_pool_offset | int > 0 + - router_dhcp_pool_size | int > 0 + - ansible_facts['distribution'] in router_rtl8153_firmware_packages + fail_msg: >- + Configure the router interface names, IPv4 addresses, and DHCP pool with + valid non-empty values. Add the target distribution's RTL8153 firmware + package to router_rtl8153_firmware_packages. + + - name: Install router packages + ansible.builtin.apt: + name: + - systemd-resolved + - "{{ router_rtl8153_firmware_packages[ansible_facts['distribution']] }}" + state: present + update_cache: true + become: true + + - name: Ensure systemd-resolved configuration directory exists + ansible.builtin.file: + path: /etc/systemd/resolved.conf.d + state: directory + owner: root + group: root + mode: "0755" + become: true + + - name: Configure LAN DNS listener + ansible.builtin.template: + src: 10-router.conf.j2 + dest: /etc/systemd/resolved.conf.d/10-router.conf + owner: root + group: root + mode: "0644" + become: true + notify: Restart systemd-resolved + + - name: Configure IPv4 forwarding + ansible.builtin.copy: + content: "net.ipv4.ip_forward=1\n" + dest: /etc/sysctl.d/99-ip-forward.conf + owner: root + group: root + mode: "0644" + become: true + notify: Apply sysctl settings + + - name: Configure predictable LAN interface name + ansible.builtin.template: + src: 10-router-lan.link.j2 + dest: /etc/systemd/network/10-router-lan.link + owner: root + group: root + mode: "0644" + become: true + + - name: Gather service facts + ansible.builtin.service_facts: + + - name: Exclude LAN interface from NetworkManager + when: ansible_facts.services['NetworkManager.service'] is defined + block: + - name: Ensure NetworkManager conf.d directory exists + ansible.builtin.file: + path: /etc/NetworkManager/conf.d + state: directory + owner: root + group: root + mode: "0755" + become: true + + - name: Configure NetworkManager to ignore LAN adapter + ansible.builtin.copy: + content: | + [keyfile] + unmanaged-devices+=mac:{{ router_lan_mac_address }} + dest: /etc/NetworkManager/conf.d/10-router-lan.conf + owner: root + group: root + mode: "0644" + become: true + register: router_networkmanager_lan_config + notify: Restart NetworkManager + + - name: Apply NetworkManager LAN exclusion before configuring LAN + ansible.builtin.meta: flush_handlers + + - name: Configure LAN DHCP server + ansible.builtin.template: + src: 10-router-lan.network.j2 + dest: /etc/systemd/network/10-router-lan.network + owner: root + group: root + mode: "0644" + become: true + notify: Restart systemd-networkd + + - name: Configure router nftables rules + ansible.builtin.template: + src: "{{ item.src }}" + dest: "/etc/nftables.d/{{ item.dest }}" + owner: root + group: root + mode: "0644" + become: true + loop: + - src: 20-router.nft.j2 + dest: 20-router.nft + notify: Restart nftables + + - name: Validate assembled nftables configuration + ansible.builtin.command: + cmd: /usr/sbin/nft -c -f /etc/nftables.conf + become: true + changed_when: false + + - name: Enable router services + ansible.builtin.systemd: + name: "{{ item }}" + enabled: true + state: started + become: true + loop: + - systemd-networkd + - systemd-resolved diff --git a/ansible/roles/router/templates/10-router-lan.link.j2 b/ansible/roles/router/templates/10-router-lan.link.j2 new file mode 100644 index 0000000..f1f3eab --- /dev/null +++ b/ansible/roles/router/templates/10-router-lan.link.j2 @@ -0,0 +1,5 @@ +[Match] +MACAddress={{ router_lan_mac_address }} + +[Link] +Name={{ router_lan_interface }} diff --git a/ansible/roles/router/templates/10-router-lan.network.j2 b/ansible/roles/router/templates/10-router-lan.network.j2 new file mode 100644 index 0000000..80d9cde --- /dev/null +++ b/ansible/roles/router/templates/10-router-lan.network.j2 @@ -0,0 +1,22 @@ +[Match] +Name={{ router_lan_interface }} + +[Network] +Address={{ router_lan_address }} +DHCPServer=yes +IPv6AcceptRA=no +# Enable .local service discovery on this LAN link. The resolved drop-in also +# enables mDNS globally; both the global and per-link settings are required. +MulticastDNS=yes +# Avoid legacy LLMNR name resolution and responder behavior on the LAN. +LLMNR=no + +[DHCPServer] +PoolOffset={{ router_dhcp_pool_offset }} +PoolSize={{ router_dhcp_pool_size }} +EmitDNS=yes +DNS={{ router_dhcp_dns_servers | join(' ') }} +EmitRouter=yes +Router={{ router_dhcp_gateway }} +DefaultLeaseTimeSec={{ router_dhcp_default_lease_time }} +MaxLeaseTimeSec={{ router_dhcp_max_lease_time }} diff --git a/ansible/roles/router/templates/10-router.conf.j2 b/ansible/roles/router/templates/10-router.conf.j2 new file mode 100644 index 0000000..bf336aa --- /dev/null +++ b/ansible/roles/router/templates/10-router.conf.j2 @@ -0,0 +1,11 @@ +[Resolve] +# Serve DHCP clients through the router's caching DNS stub. +DNSStubListenerExtra={{ router_dhcp_gateway }} + +# Advertise and resolve .local services on the LAN. This is also enabled per-link +# in the networkd configuration, which is required for mDNS traffic on this link. +MulticastDNS=yes + +# Do not use legacy single-label LLMNR naming or send bare labels to unicast DNS. +LLMNR=no +ResolveUnicastSingleLabel=no diff --git a/ansible/roles/router/templates/20-router.nft.j2 b/ansible/roles/router/templates/20-router.nft.j2 new file mode 100644 index 0000000..870d4b9 --- /dev/null +++ b/ansible/roles/router/templates/20-router.nft.j2 @@ -0,0 +1,16 @@ +add rule inet filter input iifname "{{ router_lan_interface }}" accept + +add table inet router + +add chain inet router forward { + type filter hook forward priority 0; policy drop; + + ct state { established, related } accept + iifname "{{ router_lan_interface }}" oifname "{{ router_wan_interface }}" accept +} + +add chain inet router postrouting { + type nat hook postrouting priority 100; + + oifname "{{ router_wan_interface }}" ip saddr {{ router_lan_subnet }} masquerade +} diff --git a/ansible/roles/systemd/tasks/main.yml b/ansible/roles/systemd/tasks/main.yml index 60b5326..6a1d6e1 100644 --- a/ansible/roles/systemd/tasks/main.yml +++ b/ansible/roles/systemd/tasks/main.yml @@ -5,6 +5,28 @@ state: present become: true +- name: Remove Avahi mDNS daemon + ansible.builtin.apt: + name: avahi-daemon + state: absent + purge: true + become: true + +- name: Remove NSS mDNS module + ansible.builtin.apt: + name: libnss-mdns + state: absent + purge: true + become: true + +- name: Use systemd-resolved for local hostname resolution + ansible.builtin.lineinfile: + path: /etc/nsswitch.conf + regexp: '^(hosts:\s+.*)\s+mdns4_minimal\s+\[NOTFOUND=return\](.*)$' + line: '\1\2' + backrefs: true + become: true + - name: Deploy systemd-resolved configuration ansible.builtin.copy: src: resolved.conf @@ -15,6 +37,27 @@ become: true notify: Restart DNS services in order +- name: Ensure systemd-resolved drop-in directory exists + ansible.builtin.file: + path: /etc/systemd/resolved.conf.d + state: directory + owner: root + group: root + mode: "0755" + become: true + +- name: Enable Multicast DNS after vendor drop-ins + ansible.builtin.copy: + content: | + [Resolve] + MulticastDNS=yes + dest: /etc/systemd/resolved.conf.d/90-multicast-dns.conf + owner: root + group: root + mode: "0644" + become: true + notify: Restart DNS services in order + - name: Enable and start systemd-resolved ansible.builtin.systemd: name: systemd-resolved @@ -30,6 +73,9 @@ force: true become: true +- name: Gather service facts + ansible.builtin.service_facts: + - name: Configure NetworkManager to use systemd-resolved when: ansible_facts.services['NetworkManager.service'] is defined block: