From d715244b76191469f161a06d27d3c161c8b9e428 Mon Sep 17 00:00:00 2001 From: Bart Van Geyt Date: Tue, 7 Jul 2026 12:45:42 +0200 Subject: [PATCH] Phase 1: Ansible host baseline for Ubuntu VM Idempotent host configuration targeting Ubuntu 24.04 with ZFS on a dedicated second disk. Role-based platform-infra/ansible: - base: apt packages, timezone, unattended security upgrades. - zfs: install ZFS, create pool on a dedicated disk (guarded against wiping a non-empty disk), create platform datasets + customers parent per docs/03; docker dataset mounted at /var/lib/docker. - docker: Docker Engine + Compose plugin, daemon.json written before first start so the native zfs storage driver initializes on the ZFS data-root; per-site network address pool preconfigured. - firewall: nftables inbound default-deny in a dedicated table that never flushes Docker's rules; container outbound SMTP blocked via a DOCKER-USER jump applied by a systemd oneshot. - ssh_hardening: key-first SSH with an anti-lockout assertion, config validation gate, and the sftponly group for Phase 4 SFTP accounts. Includes ansible.cfg, requirements.yml, inventory example, group_vars with safety notes, and a run guide. Real inventory (hosts.yml) is git-ignored. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + platform-infra/README.md | 20 +++-- platform-infra/ansible/README.md | 78 +++++++++++++++++++ platform-infra/ansible/ansible.cfg | 12 +++ platform-infra/ansible/group_vars/all.yml | 56 +++++++++++++ .../ansible/inventory/hosts.yml.example | 10 +++ platform-infra/ansible/requirements.yml | 6 ++ .../ansible/roles/base/tasks/main.yml | 39 ++++++++++ .../ansible/roles/docker/handlers/main.yml | 5 ++ .../ansible/roles/docker/tasks/main.yml | 62 +++++++++++++++ .../roles/docker/templates/daemon.json.j2 | 14 ++++ .../ansible/roles/firewall/handlers/main.yml | 11 +++ .../ansible/roles/firewall/tasks/main.yml | 74 ++++++++++++++++++ .../templates/heleos-docker-egress.sh.j2 | 37 +++++++++ .../firewall/templates/heleos-input.nft.j2 | 28 +++++++ .../roles/ssh_hardening/handlers/main.yml | 5 ++ .../roles/ssh_hardening/tasks/main.yml | 36 +++++++++ .../templates/10-heleos-hardening.conf.j2 | 21 +++++ .../ansible/roles/zfs/tasks/main.yml | 64 +++++++++++++++ platform-infra/ansible/site.yml | 22 ++++++ 20 files changed, 598 insertions(+), 5 deletions(-) create mode 100644 platform-infra/ansible/README.md create mode 100644 platform-infra/ansible/ansible.cfg create mode 100644 platform-infra/ansible/group_vars/all.yml create mode 100644 platform-infra/ansible/inventory/hosts.yml.example create mode 100644 platform-infra/ansible/requirements.yml create mode 100644 platform-infra/ansible/roles/base/tasks/main.yml create mode 100644 platform-infra/ansible/roles/docker/handlers/main.yml create mode 100644 platform-infra/ansible/roles/docker/tasks/main.yml create mode 100644 platform-infra/ansible/roles/docker/templates/daemon.json.j2 create mode 100644 platform-infra/ansible/roles/firewall/handlers/main.yml create mode 100644 platform-infra/ansible/roles/firewall/tasks/main.yml create mode 100644 platform-infra/ansible/roles/firewall/templates/heleos-docker-egress.sh.j2 create mode 100644 platform-infra/ansible/roles/firewall/templates/heleos-input.nft.j2 create mode 100644 platform-infra/ansible/roles/ssh_hardening/handlers/main.yml create mode 100644 platform-infra/ansible/roles/ssh_hardening/tasks/main.yml create mode 100644 platform-infra/ansible/roles/ssh_hardening/templates/10-heleos-hardening.conf.j2 create mode 100644 platform-infra/ansible/roles/zfs/tasks/main.yml create mode 100644 platform-infra/ansible/site.yml diff --git a/.gitignore b/.gitignore index bc2aafe..1f985ac 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ Thumbs.db # Ansible *.retry +platform-infra/ansible/inventory/hosts.yml +platform-infra/ansible/.ansible/ +platform-infra/ansible/collections/ # Python (control-panel CLI) __pycache__/ diff --git a/platform-infra/README.md b/platform-infra/README.md index c252f30..654837a 100644 --- a/platform-infra/README.md +++ b/platform-infra/README.md @@ -1,11 +1,21 @@ # platform-infra -Host baseline and platform services as code (Phase 1–2). Empty until then. +Host baseline and platform services as code. -**Planned contents:** -- Ansible roles: ZFS pool/datasets, Docker, nftables (Docker-aware), SSH - hardening, egress filtering, automysqlbackup, ZFS snapshot/`send` jobs. -- Base compose projects: Traefik, shared MariaDB, Forgejo + registry, +## `ansible/` — Phase 1 host baseline ✅ + +Idempotent Ubuntu 24.04 host config. See +[ansible/README.md](ansible/README.md) for how to run it. + +Roles: `base` (packages, timezone, unattended-upgrades) → `zfs` (pool on a +dedicated disk + platform datasets) → `docker` (Engine + Compose, data-root on +ZFS, zfs storage driver) → `firewall` (nftables inbound + container SMTP egress +block) → `ssh_hardening` (key-first SSH, `sftponly` group). + +## Planned (later phases) + +- **Phase 2 base compose projects:** Traefik, shared MariaDB, Forgejo + registry, Prometheus/Grafana/Loki, Uptime-Kuma. +- **Phase 5 Ansible:** automysqlbackup, ZFS snapshot/`send` jobs. See [../docs/07-repo-layout-gitops.md](../docs/07-repo-layout-gitops.md). diff --git a/platform-infra/ansible/README.md b/platform-infra/ansible/README.md new file mode 100644 index 0000000..e732ea7 --- /dev/null +++ b/platform-infra/ansible/README.md @@ -0,0 +1,78 @@ +# Phase 1 — Host baseline (Ansible) + +Idempotent host configuration for the heleos platform, targeting **Ubuntu 24.04 +LTS**. Roles: base packages → ZFS pool/datasets → Docker (data-root on ZFS) → +nftables firewall + container egress filter → SSH hardening. + +## Prerequisites + +1. An Ubuntu 24.04 VM you can SSH into as a **sudo-capable user**. +2. A **dedicated second virtual disk** attached to the VM for the ZFS pool + (e.g. `/dev/sdb` or `/dev/vdb`) — separate from the OS disk. +3. Ansible installed on your workstation (`pipx install ansible` or apt). + +## Configure + +```bash +cd platform-infra/ansible +ansible-galaxy collection install -r requirements.yml + +cp inventory/hosts.yml.example inventory/hosts.yml # edit host/user (git-ignored) +$EDITOR group_vars/all.yml # set zfs_pool_disk, keys, etc. +``` + +Key variables in `group_vars/all.yml`: + +| Variable | Meaning | +|----------|---------| +| `zfs_pool_disk` | The dedicated disk for the pool. **Its contents will be destroyed.** | +| `zfs_pool_force` | Must be `true` to create a pool on a non-empty disk (safety gate). | +| `admin_authorized_keys` | Public keys for the admin — required before disabling passwords. | +| `ssh_disable_password_auth` | Leave `false` until key login is verified, then flip to `true`. | +| `smtp_relay_host` | Optional; if set, containers may reach SMTP only on this host. | + +## Run + +```bash +ansible-playbook site.yml --check # dry run (note: first run can't fully + # check tasks that depend on ZFS/Docker + # not yet present) +ansible-playbook site.yml # apply +``` + +Run a single layer with tags: `--tags zfs`, `--tags docker`, `--tags firewall`, +`--tags ssh`, `--tags base`. + +## Safety notes + +- **ZFS is destructive:** the play refuses to create a pool on a disk that + already has a filesystem/partition unless `zfs_pool_force: true`. Double-check + `zfs_pool_disk` points at the empty spare disk, not the OS disk. For production + prefer a stable `/dev/disk/by-id/...` path over `/dev/sdb`. +- **SSH lock-out:** the play asserts that `admin_authorized_keys` is non-empty + before it will disable password authentication. Verify you can log in with your + key **before** setting `ssh_disable_password_auth: true`. +- **Firewall coexistence:** host inbound rules live in a dedicated `inet heleos` + nftables table and never flush the global ruleset, so Docker's own iptables/nft + rules are left intact. Container SMTP egress is blocked via a `DOCKER-USER` + jump applied by the `heleos-docker-egress` service. + +## What this sets up + +- ZFS pool `tank` with platform datasets and the `customers` parent + (see [../../docs/03-naming-conventions.md](../../docs/03-naming-conventions.md)). +- Docker Engine + Compose plugin, data-root on `tank/platform/docker` using the + native `zfs` storage driver; per-site network address pool preconfigured. +- nftables default-deny inbound (allow SSH/80/443 + established + loopback + + ICMP); outbound SMTP blocked from containers. +- Hardened SSH (key-first, root prohibit-password) and the `sftponly` group that + per-customer SFTP accounts will join in Phase 4. + +## Verify after running + +```bash +zpool status && zfs list +docker info | grep -E 'Storage Driver|Docker Root Dir' +sudo nft list table inet heleos +sudo iptables -S DOCKER-USER +``` diff --git a/platform-infra/ansible/ansible.cfg b/platform-infra/ansible/ansible.cfg new file mode 100644 index 0000000..293471f --- /dev/null +++ b/platform-infra/ansible/ansible.cfg @@ -0,0 +1,12 @@ +[defaults] +inventory = inventory/hosts.yml +roles_path = roles +host_key_checking = True +retry_files_enabled = False +stdout_callback = yaml +interpreter_python = auto_silent +collections_path = ./.ansible/collections + +[privilege_escalation] +become = True +become_method = sudo diff --git a/platform-infra/ansible/group_vars/all.yml b/platform-infra/ansible/group_vars/all.yml new file mode 100644 index 0000000..50f671e --- /dev/null +++ b/platform-infra/ansible/group_vars/all.yml @@ -0,0 +1,56 @@ +# ───────────────────────────────────────────────────────────────────────────── +# heleos platform — host baseline variables (Phase 1) +# Edit these to match your environment before running the playbook. +# ───────────────────────────────────────────────────────────────────────────── + +# --- General ---------------------------------------------------------------- +host_timezone: "Europe/Brussels" + +# --- ZFS -------------------------------------------------------------------- +# The pool is created on a DEDICATED second virtual disk. Attach a disk to the +# VM first (e.g. /dev/sdb or /dev/vdb) and set it here. +# +# ⚠️ zpool create is DESTRUCTIVE to the target disk. The playbook refuses to +# touch a disk that already contains a filesystem/partition unless you set +# zfs_pool_force: true. For production prefer a stable /dev/disk/by-id/... path. +zfs_pool_name: tank +zfs_pool_disk: /dev/sdb +zfs_pool_force: false +zfs_compression: lz4 # lz4 (fast) or zstd (denser) + +# Datasets created under the pool. Web roots and per-site/customer datasets are +# created later by the provisioning CLI (Phase 4); here we create the fixed +# platform datasets + the customers parent. See docs/03-naming-conventions.md. +zfs_child_datasets: + - { path: "platform" } + - { path: "platform/docker", mountpoint: "/var/lib/docker" } + - { path: "platform/mariadb" } + - { path: "platform/db-backups" } + - { path: "platform/traefik" } + - { path: "platform/forgejo" } + - { path: "platform/monitoring" } + - { path: "customers" } + +# --- Docker ----------------------------------------------------------------- +# Native ZFS storage driver keeps image layers as ZFS datasets under the pool +# (data-root sits on tank/platform/docker). Switch to overlay2 only if you have +# a specific reason. +docker_storage_driver: zfs +docker_data_root: /var/lib/docker +# Address pool for the many per-site bridge networks (avoids subnet exhaustion). +docker_address_pool_base: "10.201.0.0/16" +docker_address_pool_size: 24 + +# --- Firewall (nftables + Docker egress) ------------------------------------ +ssh_port: 22 +firewall_allowed_tcp_ports: [80, 443] # SSH is added automatically via ssh_port +# Outbound SMTP from containers is blocked (spam prevention from hacked sites). +smtp_blocked_ports: [25, 465, 587] +smtp_relay_host: "" # optional: allow SMTP only to this host + +# --- SSH hardening ---------------------------------------------------------- +# ⚠️ If ssh_disable_password_auth is true you MUST provide admin_authorized_keys +# for admin_user, or you will lock yourself out. The playbook asserts this. +admin_user: "{{ ansible_user }}" +admin_authorized_keys: [] # list of public key strings +ssh_disable_password_auth: false # flip to true once key login is verified diff --git a/platform-infra/ansible/inventory/hosts.yml.example b/platform-infra/ansible/inventory/hosts.yml.example new file mode 100644 index 0000000..2103114 --- /dev/null +++ b/platform-infra/ansible/inventory/hosts.yml.example @@ -0,0 +1,10 @@ +# Copy to hosts.yml and edit for your VM. +# hosts.yml is git-ignored (it may contain a real address/user). +all: + children: + hosting: + hosts: + heleos-test: + ansible_host: 192.168.122.10 # your Ubuntu VM's IP + ansible_user: bart # a sudo-capable user on the VM + # ansible_ssh_private_key_file: ~/.ssh/id_ed25519 diff --git a/platform-infra/ansible/requirements.yml b/platform-infra/ansible/requirements.yml new file mode 100644 index 0000000..9edd06a --- /dev/null +++ b/platform-infra/ansible/requirements.yml @@ -0,0 +1,6 @@ +# Install with: ansible-galaxy collection install -r requirements.yml +collections: + - name: ansible.posix # authorized_key, sysctl, mount helpers + version: ">=1.5.0" + - name: community.general # optional helpers (timezone, etc.) + version: ">=8.0.0" diff --git a/platform-infra/ansible/roles/base/tasks/main.yml b/platform-infra/ansible/roles/base/tasks/main.yml new file mode 100644 index 0000000..7af8bf5 --- /dev/null +++ b/platform-infra/ansible/roles/base/tasks/main.yml @@ -0,0 +1,39 @@ +--- +- name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + +- name: Install base packages + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + - nftables + - acl + - rsync + - python3 + - unattended-upgrades + state: present + +- name: Get current timezone + ansible.builtin.command: timedatectl show -p Timezone --value + register: current_tz + changed_when: false + +- name: Set system timezone + ansible.builtin.command: "timedatectl set-timezone {{ host_timezone }}" + when: + - host_timezone | length > 0 + - current_tz.stdout != host_timezone + +- name: Enable unattended security upgrades + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + owner: root + group: root + mode: "0644" + content: | + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; diff --git a/platform-infra/ansible/roles/docker/handlers/main.yml b/platform-infra/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000..37b5807 --- /dev/null +++ b/platform-infra/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart docker + ansible.builtin.systemd: + name: docker + state: restarted diff --git a/platform-infra/ansible/roles/docker/tasks/main.yml b/platform-infra/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000..9a2eadb --- /dev/null +++ b/platform-infra/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,62 @@ +--- +- name: Create Docker apt keyring dir + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + +- name: Add Docker's official GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + +- name: Add Docker apt repository + ansible.builtin.apt_repository: + repo: >- + deb [arch={{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }} + signed-by=/etc/apt/keyrings/docker.asc] + https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable + filename: docker + state: present + +- name: Ensure the ZFS docker dataset is mounted at the data-root + ansible.builtin.command: "zfs get -H -o value mounted {{ zfs_pool_name }}/platform/docker" + register: docker_ds_mounted + changed_when: false + failed_when: docker_ds_mounted.stdout != "yes" + # The zfs role creates tank/platform/docker with mountpoint=/var/lib/docker. + # This guards against installing Docker onto the wrong (non-ZFS) filesystem. + +- name: Ensure /etc/docker exists + ansible.builtin.file: + path: /etc/docker + state: directory + mode: "0755" + +- name: Write Docker daemon configuration (before first start, so the zfs + storage driver initializes on the empty ZFS data-root) + ansible.builtin.template: + src: daemon.json.j2 + dest: /etc/docker/daemon.json + owner: root + group: root + mode: "0644" + notify: Restart docker + +- name: Install Docker Engine + Compose plugin + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + update_cache: true + +- name: Ensure Docker is enabled and running + ansible.builtin.systemd: + name: docker + enabled: true + state: started diff --git a/platform-infra/ansible/roles/docker/templates/daemon.json.j2 b/platform-infra/ansible/roles/docker/templates/daemon.json.j2 new file mode 100644 index 0000000..bcbc3ea --- /dev/null +++ b/platform-infra/ansible/roles/docker/templates/daemon.json.j2 @@ -0,0 +1,14 @@ +{ + "data-root": "{{ docker_data_root }}", + "storage-driver": "{{ docker_storage_driver }}", + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "live-restore": true, + "userland-proxy": false, + "default-address-pools": [ + { "base": "{{ docker_address_pool_base }}", "size": {{ docker_address_pool_size }} } + ] +} diff --git a/platform-infra/ansible/roles/firewall/handlers/main.yml b/platform-infra/ansible/roles/firewall/handlers/main.yml new file mode 100644 index 0000000..0f57ca4 --- /dev/null +++ b/platform-infra/ansible/roles/firewall/handlers/main.yml @@ -0,0 +1,11 @@ +--- +- name: Reload nftables + ansible.builtin.systemd: + name: nftables + state: reloaded + +- name: Reapply docker egress rules + ansible.builtin.systemd: + name: heleos-docker-egress.service + daemon_reload: true + state: restarted diff --git a/platform-infra/ansible/roles/firewall/tasks/main.yml b/platform-infra/ansible/roles/firewall/tasks/main.yml new file mode 100644 index 0000000..33462c3 --- /dev/null +++ b/platform-infra/ansible/roles/firewall/tasks/main.yml @@ -0,0 +1,74 @@ +--- +# Host inbound filtering lives in a DEDICATED nftables table (inet heleos) so it +# never flushes or clobbers the ip/ip6 filter+nat tables that Docker manages. +- name: Ensure nftables include dir exists + ansible.builtin.file: + path: /etc/nftables.d + state: directory + mode: "0755" + +- name: Install root nftables config (include-only, no global flush) + ansible.builtin.copy: + dest: /etc/nftables.conf + owner: root + group: root + mode: "0755" + content: | + #!/usr/sbin/nft -f + # Managed by heleos platform-infra. Include per-table files WITHOUT + # flushing the global ruleset, so Docker's own tables are left intact. + include "/etc/nftables.d/*.nft" + notify: Reload nftables + +- name: Install heleos host input table + ansible.builtin.template: + src: heleos-input.nft.j2 + dest: /etc/nftables.d/heleos-input.nft + owner: root + group: root + mode: "0644" + notify: Reload nftables + +- name: Enable and start nftables + ansible.builtin.systemd: + name: nftables + enabled: true + state: started + +# --- Container egress SMTP filtering (via DOCKER-USER) ----------------------- +- name: Install container egress filter script + ansible.builtin.template: + src: heleos-docker-egress.sh.j2 + dest: /usr/local/sbin/heleos-docker-egress.sh + owner: root + group: root + mode: "0755" + notify: Reapply docker egress rules + +- name: Install container egress systemd unit + ansible.builtin.copy: + dest: /etc/systemd/system/heleos-docker-egress.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=heleos: block outbound SMTP from containers (DOCKER-USER) + After=docker.service + Requires=docker.service + + [Service] + Type=oneshot + ExecStart=/usr/local/sbin/heleos-docker-egress.sh + RemainAfterExit=yes + + [Install] + WantedBy=multi-user.target + notify: Reapply docker egress rules + +- name: Enable container egress service + ansible.builtin.systemd: + name: heleos-docker-egress.service + enabled: true + daemon_reload: true + state: started diff --git a/platform-infra/ansible/roles/firewall/templates/heleos-docker-egress.sh.j2 b/platform-infra/ansible/roles/firewall/templates/heleos-docker-egress.sh.j2 new file mode 100644 index 0000000..216029f --- /dev/null +++ b/platform-infra/ansible/roles/firewall/templates/heleos-docker-egress.sh.j2 @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# heleos: block outbound SMTP from containers (spam prevention from hacked sites). +# Managed by Ansible. Implemented as a dedicated chain jumped from DOCKER-USER, +# which Docker evaluates before its own FORWARD rules. Idempotent on re-run. +set -euo pipefail + +PORTS="{{ smtp_blocked_ports | join(',') }}" +RELAY="{{ smtp_relay_host }}" + +# DOCKER-USER only exists once the Docker daemon has set up networking. Wait for it. +for _ in $(seq 1 30); do + if iptables -L DOCKER-USER -n >/dev/null 2>&1; then + break + fi + sleep 1 +done + +# (Re)build our chain from scratch. +iptables -N HELEOS-EGRESS 2>/dev/null || true +iptables -F HELEOS-EGRESS + +# Let established/return traffic through fast. +iptables -A HELEOS-EGRESS -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN + +{% if smtp_relay_host | length > 0 %} +# Permit SMTP only to the approved relay. +iptables -A HELEOS-EGRESS -p tcp -d "${RELAY}" -m multiport --dports "${PORTS}" -j RETURN +{% endif %} + +# Reject all other outbound SMTP. +iptables -A HELEOS-EGRESS -p tcp -m multiport --dports "${PORTS}" \ + -j REJECT --reject-with icmp-admin-prohibited + +# Ensure DOCKER-USER jumps into our chain exactly once. +if ! iptables -C DOCKER-USER -j HELEOS-EGRESS 2>/dev/null; then + iptables -I DOCKER-USER -j HELEOS-EGRESS +fi diff --git a/platform-infra/ansible/roles/firewall/templates/heleos-input.nft.j2 b/platform-infra/ansible/roles/firewall/templates/heleos-input.nft.j2 new file mode 100644 index 0000000..dbe2c57 --- /dev/null +++ b/platform-infra/ansible/roles/firewall/templates/heleos-input.nft.j2 @@ -0,0 +1,28 @@ +#!/usr/sbin/nft -f +# heleos host inbound firewall — managed by Ansible. Do not edit by hand. +# +# We own ONLY the `inet heleos` table. The delete-then-create pattern makes +# re-applying idempotent and never touches Docker's ip/ip6 filter+nat tables. + +table inet heleos {} +delete table inet heleos + +table inet heleos { + chain input { + type filter hook input priority filter; policy drop; + + ct state established,related accept + ct state invalid drop + iif "lo" accept + + # ICMP / ICMPv6 (ping + path-MTU discovery) + ip protocol icmp accept + ip6 nexthdr ipv6-icmp accept + + # Admin SSH + public web ports + tcp dport { {{ ssh_port }}{% for p in firewall_allowed_tcp_ports %}, {{ p }}{% endfor %} } accept + } +} + +# Container FORWARD/NAT (east-west isolation + egress) is handled by Docker in +# its own tables plus the heleos-docker-egress service. See the firewall role. diff --git a/platform-infra/ansible/roles/ssh_hardening/handlers/main.yml b/platform-infra/ansible/roles/ssh_hardening/handlers/main.yml new file mode 100644 index 0000000..d76899b --- /dev/null +++ b/platform-infra/ansible/roles/ssh_hardening/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart ssh + ansible.builtin.systemd: + name: ssh + state: restarted diff --git a/platform-infra/ansible/roles/ssh_hardening/tasks/main.yml b/platform-infra/ansible/roles/ssh_hardening/tasks/main.yml new file mode 100644 index 0000000..c63f4a4 --- /dev/null +++ b/platform-infra/ansible/roles/ssh_hardening/tasks/main.yml @@ -0,0 +1,36 @@ +--- +- name: Guard against lock-out when disabling password auth + ansible.builtin.assert: + that: + - not ssh_disable_password_auth or (admin_authorized_keys | length > 0) + fail_msg: >- + ssh_disable_password_auth is true but admin_authorized_keys is empty. + Add the admin's public key(s) first, or you will lock yourself out. + +- name: Install admin authorized keys + ansible.posix.authorized_key: + user: "{{ admin_user }}" + key: "{{ item }}" + state: present + loop: "{{ admin_authorized_keys }}" + when: admin_authorized_keys | length > 0 + +- name: Create sftponly group (per-customer SFTP users join this in Phase 4) + ansible.builtin.group: + name: sftponly + state: present + +- name: Deploy SSH hardening drop-in + ansible.builtin.template: + src: 10-heleos-hardening.conf.j2 + dest: /etc/ssh/sshd_config.d/10-heleos-hardening.conf + owner: root + group: root + mode: "0644" + notify: Restart ssh + +- name: Validate the effective sshd configuration + ansible.builtin.command: /usr/sbin/sshd -t + changed_when: false + # Runs after the template task; if the config is invalid the play fails here + # and the Restart ssh handler never fires, leaving the running sshd untouched. diff --git a/platform-infra/ansible/roles/ssh_hardening/templates/10-heleos-hardening.conf.j2 b/platform-infra/ansible/roles/ssh_hardening/templates/10-heleos-hardening.conf.j2 new file mode 100644 index 0000000..a652b3c --- /dev/null +++ b/platform-infra/ansible/roles/ssh_hardening/templates/10-heleos-hardening.conf.j2 @@ -0,0 +1,21 @@ +# heleos SSH hardening — managed by Ansible. Do not edit by hand. + +PermitRootLogin prohibit-password +PubkeyAuthentication yes +PasswordAuthentication {{ 'no' if ssh_disable_password_auth else 'yes' }} +KbdInteractiveAuthentication no +X11Forwarding no +AllowAgentForwarding no +MaxAuthTries 4 +LoginGraceTime 30 +ClientAliveInterval 300 +ClientAliveCountMax 2 + +# SFTP-only tenant accounts (created per customer in Phase 4). ChrootDirectory +# is set per user at provision time; this block enforces the shared restrictions. +Match Group sftponly + ForceCommand internal-sftp + AllowTcpForwarding no + X11Forwarding no + PermitTunnel no + PermitTTY no diff --git a/platform-infra/ansible/roles/zfs/tasks/main.yml b/platform-infra/ansible/roles/zfs/tasks/main.yml new file mode 100644 index 0000000..d6ee406 --- /dev/null +++ b/platform-infra/ansible/roles/zfs/tasks/main.yml @@ -0,0 +1,64 @@ +--- +- name: Install ZFS userland + kernel module + ansible.builtin.apt: + name: zfsutils-linux + state: present + +- name: Ensure the ZFS kernel module is loaded + community.general.modprobe: + name: zfs + state: present + +# --- Pool creation (guarded & destructive) ---------------------------------- +- name: Check whether the ZFS pool already exists + ansible.builtin.command: "zpool list -H -o name {{ zfs_pool_name }}" + register: zpool_check + changed_when: false + failed_when: false + +- name: Record pool existence + ansible.builtin.set_fact: + zpool_exists: "{{ zpool_check.rc == 0 }}" + +- name: Probe the target disk for existing data + ansible.builtin.command: "lsblk -nro FSTYPE,MOUNTPOINT,PARTTYPE {{ zfs_pool_disk }}" + register: disk_probe + changed_when: false + when: not zpool_exists + +- name: Refuse to create a pool on a non-empty disk unless forced + ansible.builtin.assert: + that: + - (disk_probe.stdout | trim | length == 0) or zfs_pool_force + fail_msg: >- + {{ zfs_pool_disk }} appears to already contain data + ({{ disk_probe.stdout | trim }}). Refusing to create the pool. Verify you + picked the right disk, then set zfs_pool_force=true to override. + when: not zpool_exists + +- name: Create the ZFS pool on the dedicated disk + ansible.builtin.command: >- + zpool create {{ '-f ' if zfs_pool_force else '' }}-o ashift=12 + -O compression={{ zfs_compression }} + -O atime=off + -O xattr=sa + -O acltype=posixacl + -O mountpoint=/{{ zfs_pool_name }} + {{ zfs_pool_name }} {{ zfs_pool_disk }} + when: not zpool_exists + +# --- Datasets --------------------------------------------------------------- +- name: List existing datasets + ansible.builtin.command: "zfs list -H -o name" + register: zfs_existing + changed_when: false + +- name: Create platform + customers datasets + ansible.builtin.command: >- + zfs create -p + {% if item.mountpoint is defined %}-o mountpoint={{ item.mountpoint }}{% endif %} + {{ zfs_pool_name }}/{{ item.path }} + loop: "{{ zfs_child_datasets }}" + loop_control: + label: "{{ zfs_pool_name }}/{{ item.path }}" + when: (zfs_pool_name ~ '/' ~ item.path) not in zfs_existing.stdout_lines diff --git a/platform-infra/ansible/site.yml b/platform-infra/ansible/site.yml new file mode 100644 index 0000000..c5b3918 --- /dev/null +++ b/platform-infra/ansible/site.yml @@ -0,0 +1,22 @@ +--- +# heleos platform — host baseline (Phase 1) +# Usage: +# ansible-galaxy collection install -r requirements.yml +# ansible-playbook site.yml +# +# Roles run in dependency order: base packages → ZFS pool/datasets → Docker +# (data-root on ZFS) → firewall → SSH hardening. +- name: heleos host baseline + hosts: hosting + become: true + roles: + - role: base + tags: [base] + - role: zfs + tags: [zfs] + - role: docker + tags: [docker] + - role: firewall + tags: [firewall] + - role: ssh_hardening + tags: [ssh]