diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa162ea --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/ansible/inventories/production/host_vars/localhost/vault.yml diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5138376..f0fe6c1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -77,6 +77,7 @@ "nftables", "podman", "router", + "scanner", "scripts", "shell", "ssh", diff --git a/README.md b/README.md index 12de007..39b11fc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ SSH. | `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 | +| `scanner` | `sane_airscan` | Static eSCL/WSD network-scanner configuration without Avahi discovery | | `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 | @@ -79,6 +80,26 @@ 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. +## Network Scanner Configuration + +The optional `sane_airscan` role installs Skanpage and configures eSCL/WSD +scanners with static URLs. It disables automatic DNS-SD and WS-Discovery, +avoiding an Avahi requirement at XSane or Skanpage startup. Enable it per host +and define one or more scanner URLs: + +```yaml +sane_airscan_enabled: true +sane_airscan_disable_hpaio: true +sane_airscan_devices: + - name: NPIC65336 + url: http://npic65336.local/eSCL + protocol: escl +``` + +Set `sane_airscan_disable_hpaio: true` when the same device is configured via +eSCL and XSane should not load HPLIP's Avahi-dependent SANE backend. This does +not remove HPLIP's printing support. + ## Router Configuration The `router` role is disabled by default to avoid changing a normal workstation @@ -114,6 +135,36 @@ 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. +To use a supported USB Wi-Fi adapter as the router LAN access point instead, +set the LAN adapter's permanent MAC address and enable hostapd: + +```yaml +router_lan_interface: router-ap +router_lan_mac_address: "74:19:f8:17:a7:27" +router_access_point_enabled: true +router_access_point_ssid: toms_bridge +router_access_point_country_code: DE +``` + +The access point uses WPA2 and assigns clients addresses from the existing +`router_lan_*` subnet and DHCP pool. Store its 8 to 63 character passphrase in +an untracked, encrypted Vault file. Create it interactively with: + +```bash +./scripts/create-router-access-point-vault +``` + +The script checks the WPA passphrase length, prompts for a Vault password, and +creates the following encrypted variable: + +```yaml +router_access_point_passphrase: "" +``` + +When the Vault file exists, local deployments automatically prompt for its +password. Deploy with `./scripts/deploy --tag router`. The file is ignored by +Git and must never be committed. + ## Requirements - Debian or Ubuntu system diff --git a/ansible/inventories/production/host_vars/localhost.yml b/ansible/inventories/production/host_vars/localhost.yml deleted file mode 100644 index 06776d7..0000000 --- a/ansible/inventories/production/host_vars/localhost.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -router_enabled: false -router_wan_interface: wlp2s0 diff --git a/ansible/inventories/production/host_vars/localhost/main.yml b/ansible/inventories/production/host_vars/localhost/main.yml new file mode 100644 index 0000000..057caaa --- /dev/null +++ b/ansible/inventories/production/host_vars/localhost/main.yml @@ -0,0 +1,15 @@ +--- +router_enabled: false +router_wan_interface: wlp2s0 +router_lan_interface: router-ap +router_lan_mac_address: "74:19:f8:17:a7:27" +router_access_point_enabled: true +router_access_point_ssid: toms_bridge +router_access_point_country_code: DE + +sane_airscan_enabled: true +sane_airscan_disable_hpaio: true +sane_airscan_devices: + - name: NPIC65336 + url: http://npic65336.local/eSCL + protocol: escl diff --git a/ansible/playbooks/workstation.yml b/ansible/playbooks/workstation.yml index f8e3a6f..60f568a 100644 --- a/ansible/playbooks/workstation.yml +++ b/ansible/playbooks/workstation.yml @@ -28,6 +28,8 @@ tags: [scripts] - role: systemd tags: [systemd] + - role: sane_airscan + tags: [scanner] - role: vscode tags: [vscode] - role: yakuake diff --git a/ansible/roles/router/defaults/main.yml b/ansible/roles/router/defaults/main.yml index 68529f5..ae7982b 100644 --- a/ansible/roles/router/defaults/main.yml +++ b/ansible/roles/router/defaults/main.yml @@ -18,6 +18,12 @@ router_dhcp_pool_size: 50 router_dhcp_default_lease_time: 12h router_dhcp_max_lease_time: 24h +# Optional hostapd access point that replaces the wired LAN interface. +router_access_point_enabled: false +router_access_point_ssid: "" +router_access_point_country_code: DE +router_access_point_channel: 6 + # Distribution packages that provide the RTL8153 USB Ethernet firmware patch. router_rtl8153_firmware_packages: Debian: firmware-realtek diff --git a/ansible/roles/router/handlers/main.yml b/ansible/roles/router/handlers/main.yml index e1dd355..ad0cfaa 100644 --- a/ansible/roles/router/handlers/main.yml +++ b/ansible/roles/router/handlers/main.yml @@ -23,3 +23,10 @@ become: true changed_when: false listen: Apply sysctl settings + +- name: Restart hostapd + ansible.builtin.systemd: + name: hostapd + state: restarted + become: true + when: router_lan_interface in ansible_facts.interfaces diff --git a/ansible/roles/router/tasks/main.yml b/ansible/roles/router/tasks/main.yml index 3ebc907..de28531 100644 --- a/ansible/roles/router/tasks/main.yml +++ b/ansible/roles/router/tasks/main.yml @@ -20,10 +20,28 @@ - router_dhcp_pool_offset | int > 0 - router_dhcp_pool_size | int > 0 - ansible_facts['distribution'] in router_rtl8153_firmware_packages + - >- + not (router_access_point_enabled | bool) + or (router_access_point_ssid | length > 0 and router_access_point_ssid | length <= 32) + - >- + not (router_access_point_enabled | bool) + or router_access_point_country_code is match('^[A-Z]{2}$') + - >- + not (router_access_point_enabled | bool) + or (router_access_point_channel | int >= 1 and router_access_point_channel | int <= 13) + - >- + not (router_access_point_enabled | bool) + or router_access_point_passphrase is defined + - >- + not (router_access_point_enabled | bool) + or (router_access_point_passphrase | default('') | length >= 8 + and router_access_point_passphrase | default('') | length <= 63) 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. + Configure the router interface names, IPv4 addresses, DHCP pool, and, + when enabled, access-point settings with valid values. Define an 8 to + 63 character router_access_point_passphrase in an Ansible Vault file. + Add the target distribution's RTL8153 firmware package to + router_rtl8153_firmware_packages. - name: Install router packages ansible.builtin.apt: @@ -34,6 +52,13 @@ update_cache: true become: true + - name: Install access point package + ansible.builtin.apt: + name: hostapd + state: present + become: true + when: router_access_point_enabled | bool + - name: Ensure systemd-resolved configuration directory exists ansible.builtin.file: path: /etc/systemd/resolved.conf.d @@ -113,6 +138,31 @@ become: true notify: Restart systemd-networkd + - name: Configure access point + when: router_access_point_enabled | bool + block: + - name: Configure hostapd defaults + ansible.builtin.copy: + content: | + DAEMON_CONF="/etc/hostapd/hostapd.conf" + dest: /etc/default/hostapd + owner: root + group: root + mode: "0644" + become: true + notify: Restart hostapd + + - name: Configure hostapd + ansible.builtin.template: + src: hostapd.conf.j2 + dest: /etc/hostapd/hostapd.conf + owner: root + group: root + mode: "0600" + become: true + no_log: true + notify: Restart hostapd + - name: Configure router nftables rules ansible.builtin.template: src: "{{ item.src }}" @@ -141,3 +191,20 @@ loop: - systemd-networkd - systemd-resolved + + - name: Enable access point service + ansible.builtin.systemd: + name: hostapd + masked: false + enabled: true + become: true + when: router_access_point_enabled | bool + + - name: Start access point service when LAN interface exists + ansible.builtin.systemd: + name: hostapd + state: started + become: true + when: + - router_access_point_enabled | bool + - router_lan_interface in ansible_facts.interfaces diff --git a/ansible/roles/router/templates/hostapd.conf.j2 b/ansible/roles/router/templates/hostapd.conf.j2 new file mode 100644 index 0000000..ae1a121 --- /dev/null +++ b/ansible/roles/router/templates/hostapd.conf.j2 @@ -0,0 +1,14 @@ +interface={{ router_lan_interface }} +driver=nl80211 +ssid={{ router_access_point_ssid }} +country_code={{ router_access_point_country_code }} +ieee80211d=1 +hw_mode=g +channel={{ router_access_point_channel }} +ieee80211n=1 +wmm_enabled=1 +auth_algs=1 +wpa=2 +wpa_passphrase={{ router_access_point_passphrase }} +wpa_key_mgmt=WPA-PSK +rsn_pairwise=CCMP diff --git a/ansible/roles/sane_airscan/defaults/main.yml b/ansible/roles/sane_airscan/defaults/main.yml new file mode 100644 index 0000000..bdca5a2 --- /dev/null +++ b/ansible/roles/sane_airscan/defaults/main.yml @@ -0,0 +1,4 @@ +--- +sane_airscan_enabled: false +sane_airscan_devices: [] +sane_airscan_disable_hpaio: false diff --git a/ansible/roles/sane_airscan/tasks/main.yml b/ansible/roles/sane_airscan/tasks/main.yml new file mode 100644 index 0000000..48021d9 --- /dev/null +++ b/ansible/roles/sane_airscan/tasks/main.yml @@ -0,0 +1,50 @@ +--- +- name: Configure static network scanners + when: sane_airscan_enabled | bool + block: + - name: Validate static network scanner configuration + ansible.builtin.assert: + that: + - sane_airscan_devices | length > 0 + - sane_airscan_devices | selectattr('name', 'defined') | list | length + == sane_airscan_devices | length + - sane_airscan_devices | selectattr('url', 'defined') | list | length + == sane_airscan_devices | length + - sane_airscan_devices | selectattr('protocol', 'in', ['escl', 'wsd']) | list | length + == sane_airscan_devices | length + fail_msg: >- + Configure at least one scanner with a name, URL, and either the escl + or wsd protocol. + + - name: Install scanner packages + ansible.builtin.apt: + name: + - sane-airscan + - skanpage + state: present + become: true + + - name: Disable HPLIP SANE backend + ansible.builtin.file: + path: /etc/sane.d/dll.d/hplip + state: absent + become: true + when: sane_airscan_disable_hpaio | bool + + - name: Ensure sane-airscan drop-in directory exists + ansible.builtin.file: + path: /etc/sane.d/airscan.d + state: directory + owner: root + group: root + mode: "0755" + become: true + + - name: Configure static network scanners + ansible.builtin.template: + src: airscan.conf.j2 + dest: /etc/sane.d/airscan.d/10-static-scanners.conf + owner: root + group: root + mode: "0644" + become: true diff --git a/ansible/roles/sane_airscan/templates/airscan.conf.j2 b/ansible/roles/sane_airscan/templates/airscan.conf.j2 new file mode 100644 index 0000000..f060c4d --- /dev/null +++ b/ansible/roles/sane_airscan/templates/airscan.conf.j2 @@ -0,0 +1,8 @@ +# Managed by Ansible. Configure scanners in sane_airscan_devices. +[devices] +{% for device in sane_airscan_devices %} +"{{ device.name }}" = {{ device.url }}, {{ device.protocol }} +{% endfor %} + +[options] +discovery = disable diff --git a/scripts/create-router-access-point-vault b/scripts/create-router-access-point-vault new file mode 100755 index 0000000..a42c43f --- /dev/null +++ b/scripts/create-router-access-point-vault @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +VAULT_FILE="${SCRIPT_DIR}/../ansible/inventories/production/host_vars/localhost/vault.yml" + + +if [[ $# -ne 0 ]]; then + usage >&2 + exit 1 +fi + +if [[ -e "${VAULT_FILE}" ]]; then + echo "Vault file already exists: ${VAULT_FILE}" >&2 + exit 1 +fi + +read -r -s -p "Router access point WPA passphrase: " passphrase +printf '\n' +read -r -s -p "Confirm WPA passphrase: " confirmation +printf '\n' + +if [[ "${passphrase}" != "${confirmation}" ]]; then + echo "Passphrases do not match." >&2 + exit 1 +fi + +vault_dir="$(dirname "${VAULT_FILE}")" +mkdir -p "${vault_dir}" +temp_file="$(mktemp)" +trap 'rm -f "${temp_file}"' EXIT +chmod 600 "${temp_file}" +escaped_passphrase="${passphrase//\\/\\\\}" +escaped_passphrase="${escaped_passphrase//\"/\\\"}" +printf 'router_access_point_passphrase: "%s"\n' "${escaped_passphrase}" >"${temp_file}" +unset passphrase confirmation +unset escaped_passphrase + +ansible-vault encrypt --output "${VAULT_FILE}" "${temp_file}" +chmod 600 "${VAULT_FILE}" + +echo "Created encrypted router access point vault: ${VAULT_FILE}" diff --git a/scripts/deploy b/scripts/deploy index ecbe318..b0f1df9 100755 --- a/scripts/deploy +++ b/scripts/deploy @@ -2,9 +2,11 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LOCAL_VAULT_FILE="${SCRIPT_DIR}/../ansible/inventories/production/host_vars/localhost/vault.yml" HOST="" TAG="" +ASK_VAULT_PASS=false usage() { cat <