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:
parent
e5ff798dd4
commit
d715244b76
20 changed files with 598 additions and 5 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -25,6 +25,9 @@ Thumbs.db
|
||||||
|
|
||||||
# Ansible
|
# Ansible
|
||||||
*.retry
|
*.retry
|
||||||
|
platform-infra/ansible/inventory/hosts.yml
|
||||||
|
platform-infra/ansible/.ansible/
|
||||||
|
platform-infra/ansible/collections/
|
||||||
|
|
||||||
# Python (control-panel CLI)
|
# Python (control-panel CLI)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
# platform-infra
|
# 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/` — Phase 1 host baseline ✅
|
||||||
- Ansible roles: ZFS pool/datasets, Docker, nftables (Docker-aware), SSH
|
|
||||||
hardening, egress filtering, automysqlbackup, ZFS snapshot/`send` jobs.
|
Idempotent Ubuntu 24.04 host config. See
|
||||||
- Base compose projects: Traefik, shared MariaDB, Forgejo + registry,
|
[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.
|
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).
|
See [../docs/07-repo-layout-gitops.md](../docs/07-repo-layout-gitops.md).
|
||||||
|
|
|
||||||
78
platform-infra/ansible/README.md
Normal file
78
platform-infra/ansible/README.md
Normal 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
|
||||||
|
```
|
||||||
12
platform-infra/ansible/ansible.cfg
Normal file
12
platform-infra/ansible/ansible.cfg
Normal 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
|
||||||
56
platform-infra/ansible/group_vars/all.yml
Normal file
56
platform-infra/ansible/group_vars/all.yml
Normal 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
|
||||||
10
platform-infra/ansible/inventory/hosts.yml.example
Normal file
10
platform-infra/ansible/inventory/hosts.yml.example
Normal 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
|
||||||
6
platform-infra/ansible/requirements.yml
Normal file
6
platform-infra/ansible/requirements.yml
Normal 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"
|
||||||
39
platform-infra/ansible/roles/base/tasks/main.yml
Normal file
39
platform-infra/ansible/roles/base/tasks/main.yml
Normal 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";
|
||||||
5
platform-infra/ansible/roles/docker/handlers/main.yml
Normal file
5
platform-infra/ansible/roles/docker/handlers/main.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
- name: Restart docker
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: docker
|
||||||
|
state: restarted
|
||||||
62
platform-infra/ansible/roles/docker/tasks/main.yml
Normal file
62
platform-infra/ansible/roles/docker/tasks/main.yml
Normal 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
|
||||||
14
platform-infra/ansible/roles/docker/templates/daemon.json.j2
Normal file
14
platform-infra/ansible/roles/docker/templates/daemon.json.j2
Normal 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 }} }
|
||||||
|
]
|
||||||
|
}
|
||||||
11
platform-infra/ansible/roles/firewall/handlers/main.yml
Normal file
11
platform-infra/ansible/roles/firewall/handlers/main.yml
Normal 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
|
||||||
74
platform-infra/ansible/roles/firewall/tasks/main.yml
Normal file
74
platform-infra/ansible/roles/firewall/tasks/main.yml
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
- name: Restart ssh
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: ssh
|
||||||
|
state: restarted
|
||||||
36
platform-infra/ansible/roles/ssh_hardening/tasks/main.yml
Normal file
36
platform-infra/ansible/roles/ssh_hardening/tasks/main.yml
Normal 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.
|
||||||
|
|
@ -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
|
||||||
64
platform-infra/ansible/roles/zfs/tasks/main.yml
Normal file
64
platform-infra/ansible/roles/zfs/tasks/main.yml
Normal 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
|
||||||
22
platform-infra/ansible/site.yml
Normal file
22
platform-infra/ansible/site.yml
Normal 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]
|
||||||
Loading…
Add table
Reference in a new issue