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 <noreply@anthropic.com>
This commit is contained in:
Bart Van Geyt 2026-07-07 12:45:42 +02:00
parent e5ff798dd4
commit d715244b76
20 changed files with 598 additions and 5 deletions

3
.gitignore vendored
View file

@ -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__/

View file

@ -1,11 +1,21 @@
# platform-infra
Host baseline and platform services as code (Phase 12). 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).

View file

@ -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
```

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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";

View file

@ -0,0 +1,5 @@
---
- name: Restart docker
ansible.builtin.systemd:
name: docker
state: restarted

View file

@ -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

View file

@ -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 }} }
]
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -0,0 +1,5 @@
---
- name: Restart ssh
ansible.builtin.systemd:
name: ssh
state: restarted

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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]