feat(deploy): Ansible provisioning for prod hosts (Stage 18)
Idempotent playbooks under deploy/ansible/ prepare both production hosts: docker-ce + compose plugin, a non-sudo deploy service account holding the CI deploy key, key-only sshd, default-deny ufw, fail2ban, unattended upgrades and chrony. The main host also opens 80/443/9443 and creates the external edge network; the tg host verifies direct Bot API egress (the no-VPN assumption). The application is deployed separately by the prod-deploy workflow (later phase), running as the deploy account this playbook provisions.
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
# Prod host provisioning (Stage 18)
|
||||||
|
|
||||||
|
Idempotent Ansible that prepares the two production hosts. It installs Docker, a
|
||||||
|
non-sudo `deploy` service account, SSH hardening, a default-deny firewall,
|
||||||
|
fail2ban, unattended security upgrades and time sync. It does **not** deploy the
|
||||||
|
application — that is `.gitea/workflows/prod-deploy.yaml`'s job, running as the
|
||||||
|
`deploy` account this playbook creates.
|
||||||
|
|
||||||
|
Hosts are referenced by `~/.ssh/config` aliases (`scrabble-main-ops`,
|
||||||
|
`scrabble-tg-ops`), so no IPs or key paths live in the repo.
|
||||||
|
|
||||||
|
## Prerequisites (controller)
|
||||||
|
|
||||||
|
- `ansible` with the bundled collections (`community.general`, `community.docker`,
|
||||||
|
`ansible.posix`).
|
||||||
|
- The two hosts reachable as root via the ssh-config aliases, host keys already
|
||||||
|
accepted into `known_hosts` (`host_key_checking = True`).
|
||||||
|
|
||||||
|
## One-time: the CI deploy key
|
||||||
|
|
||||||
|
The CI prod-deploy workflow logs into the hosts as `deploy` using a dedicated
|
||||||
|
key. Generate it once on the controller, authorize its public half via the
|
||||||
|
playbook, and store its private half **only** in the Gitea `PROD_SSH_KEY` secret:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh-keygen -t ed25519 -N '' -C scrabble-ci-deploy \
|
||||||
|
-f ~/.ssh/scrabble_ci_deploy_ed25519
|
||||||
|
# private half -> Gitea secret PROD_SSH_KEY (set via API); never commit it
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd deploy/ansible
|
||||||
|
ansible-playbook site.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
The playbook reads the public key from `~/.ssh/scrabble_ci_deploy_ed25519.pub` by
|
||||||
|
default; override with `-e deploy_ci_pubkey_path=/path/to/key.pub`. Re-running is
|
||||||
|
safe (idempotent) and survives a host resize.
|
||||||
|
|
||||||
|
## What each host gets
|
||||||
|
|
||||||
|
- **both** (`common`): docker-ce + compose plugin, `daemon.json` (live-restore,
|
||||||
|
10m×3 log rotation), `deploy` user (docker group, no sudo), key-only sshd,
|
||||||
|
`ufw` default-deny incoming + allow SSH, fail2ban sshd jail, unattended
|
||||||
|
upgrades, chrony, `/opt/scrabble/{config,certs,dumps,images}`.
|
||||||
|
- **main**: `ufw` opens 80/443/9443; the external `edge` docker network.
|
||||||
|
- **tg**: verifies direct `api.telegram.org` egress (the no-VPN assumption).
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[defaults]
|
||||||
|
inventory = inventory.ini
|
||||||
|
roles_path = roles
|
||||||
|
interpreter_python = /usr/bin/python3
|
||||||
|
host_key_checking = True
|
||||||
|
stdout_callback = yaml
|
||||||
|
deprecation_warnings = False
|
||||||
|
retry_files_enabled = False
|
||||||
|
|
||||||
|
[ssh_connection]
|
||||||
|
pipelining = True
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
# Service account the CI prod-deploy workflow uses to drive docker on the hosts.
|
||||||
|
# Membership in the docker group is root-equivalent (docker socket access), which
|
||||||
|
# is all the deploy workflow needs; the account is deliberately not given sudo.
|
||||||
|
deploy_user: deploy
|
||||||
|
|
||||||
|
# Public half of the dedicated CI deploy SSH key, read from the controller at run
|
||||||
|
# time. The private half is generated on the controller during provisioning and
|
||||||
|
# stored ONLY in the Gitea PROD_SSH_KEY secret; it is never committed. Override the
|
||||||
|
# path with -e deploy_ci_pubkey_path=/path/to/key.pub if the key lives elsewhere.
|
||||||
|
deploy_ci_pubkey_path: "{{ lookup('env', 'HOME') }}/.ssh/scrabble_ci_deploy_ed25519.pub"
|
||||||
|
deploy_ci_pubkey: "{{ lookup('file', deploy_ci_pubkey_path) }}"
|
||||||
|
|
||||||
|
# Base directory the deploy workflow rsyncs compose files, config, certs and dumps
|
||||||
|
# into. Owned by deploy_user so the workflow needs no elevation.
|
||||||
|
scrabble_base_dir: /opt/scrabble
|
||||||
|
|
||||||
|
# Docker daemon json-file log rotation, mirroring the compose x-logging anchor so
|
||||||
|
# the host's own containers (and any ad-hoc runs) rotate identically.
|
||||||
|
docker_log_max_size: "10m"
|
||||||
|
docker_log_max_file: "3"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Production inventory for Stage 18.
|
||||||
|
#
|
||||||
|
# Hosts resolve through the operator's ~/.ssh/config aliases, so HostName (public
|
||||||
|
# IP), User and IdentityFile live there — no IPs or key paths are committed here.
|
||||||
|
# scrabble-main-ops -> main stack host (public IP, domain erudit-game.ru)
|
||||||
|
# scrabble-tg-ops -> Telegram bot host (direct Bot API egress, no VPN)
|
||||||
|
|
||||||
|
[main]
|
||||||
|
scrabble-main-ops
|
||||||
|
|
||||||
|
[tg]
|
||||||
|
scrabble-tg-ops
|
||||||
|
|
||||||
|
[prod:children]
|
||||||
|
main
|
||||||
|
tg
|
||||||
|
|
||||||
|
[prod:vars]
|
||||||
|
ansible_user=root
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
- name: restart docker
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: docker
|
||||||
|
state: restarted
|
||||||
|
|
||||||
|
- name: reload sshd
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: ssh
|
||||||
|
state: reloaded
|
||||||
|
|
||||||
|
- name: restart fail2ban
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: fail2ban
|
||||||
|
state: restarted
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
# Common baseline applied to both prod hosts: Docker engine, a non-sudo deploy
|
||||||
|
# service account, SSH hardening, a default-deny firewall, fail2ban, unattended
|
||||||
|
# security upgrades and time sync. Every task is idempotent.
|
||||||
|
|
||||||
|
- name: Install base packages
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name:
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
- gnupg
|
||||||
|
- ufw
|
||||||
|
- fail2ban
|
||||||
|
- unattended-upgrades
|
||||||
|
- chrony
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
cache_valid_time: 3600
|
||||||
|
|
||||||
|
# --- Docker engine (official repo; trixie is published upstream) ---------------
|
||||||
|
|
||||||
|
- name: Create apt keyring directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/apt/keyrings
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Install Docker apt GPG key
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: https://download.docker.com/linux/debian/gpg
|
||||||
|
dest: /etc/apt/keyrings/docker.asc
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Add Docker apt repository
|
||||||
|
ansible.builtin.apt_repository:
|
||||||
|
repo: >-
|
||||||
|
deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc]
|
||||||
|
https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable
|
||||||
|
filename: docker
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Install Docker engine and the 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: Configure the Docker daemon (live-restore + log rotation)
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: daemon.json.j2
|
||||||
|
dest: /etc/docker/daemon.json
|
||||||
|
mode: "0644"
|
||||||
|
notify: restart docker
|
||||||
|
|
||||||
|
- name: Enable and start Docker
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: docker
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
# --- Deploy service account ----------------------------------------------------
|
||||||
|
|
||||||
|
- name: Create the deploy service account
|
||||||
|
ansible.builtin.user:
|
||||||
|
name: "{{ deploy_user }}"
|
||||||
|
groups: docker
|
||||||
|
append: true
|
||||||
|
shell: /bin/bash
|
||||||
|
create_home: true
|
||||||
|
|
||||||
|
- name: Ensure the deploy .ssh directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "/home/{{ deploy_user }}/.ssh"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ deploy_user }}"
|
||||||
|
group: "{{ deploy_user }}"
|
||||||
|
mode: "0700"
|
||||||
|
|
||||||
|
- name: Authorize the CI deploy SSH key (exclusive)
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "/home/{{ deploy_user }}/.ssh/authorized_keys"
|
||||||
|
content: "{{ deploy_ci_pubkey }}\n"
|
||||||
|
owner: "{{ deploy_user }}"
|
||||||
|
group: "{{ deploy_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
# --- SSH hardening -------------------------------------------------------------
|
||||||
|
|
||||||
|
- name: Harden sshd (key-only auth)
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: sshd-hardening.conf.j2
|
||||||
|
dest: /etc/ssh/sshd_config.d/10-scrabble-hardening.conf
|
||||||
|
mode: "0644"
|
||||||
|
validate: sshd -t -f %s
|
||||||
|
notify: reload sshd
|
||||||
|
|
||||||
|
# --- Firewall (default deny incoming) ------------------------------------------
|
||||||
|
# SSH is allowed before the policy flips so enabling ufw never locks us out.
|
||||||
|
|
||||||
|
- name: Allow SSH through the firewall
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
name: OpenSSH
|
||||||
|
|
||||||
|
- name: Default-deny incoming, allow outgoing
|
||||||
|
community.general.ufw:
|
||||||
|
direction: "{{ item.direction }}"
|
||||||
|
policy: "{{ item.policy }}"
|
||||||
|
loop:
|
||||||
|
- { direction: incoming, policy: deny }
|
||||||
|
- { direction: outgoing, policy: allow }
|
||||||
|
|
||||||
|
- name: Enable the firewall
|
||||||
|
community.general.ufw:
|
||||||
|
state: enabled
|
||||||
|
|
||||||
|
# --- fail2ban ------------------------------------------------------------------
|
||||||
|
|
||||||
|
- name: Configure the fail2ban sshd jail
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: jail.local.j2
|
||||||
|
dest: /etc/fail2ban/jail.local
|
||||||
|
mode: "0644"
|
||||||
|
notify: restart fail2ban
|
||||||
|
|
||||||
|
- name: Enable and start fail2ban
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: fail2ban
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
# --- Unattended security upgrades + time sync ----------------------------------
|
||||||
|
|
||||||
|
- name: Enable unattended upgrades
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/apt/apt.conf.d/20auto-upgrades
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
APT::Periodic::Update-Package-Lists "1";
|
||||||
|
APT::Periodic::Unattended-Upgrade "1";
|
||||||
|
|
||||||
|
- name: Enable and start chrony
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: chrony
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
# --- Deploy directories --------------------------------------------------------
|
||||||
|
|
||||||
|
- name: Create the scrabble base directories
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ scrabble_base_dir }}/{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ deploy_user }}"
|
||||||
|
group: "{{ deploy_user }}"
|
||||||
|
mode: "0750"
|
||||||
|
loop:
|
||||||
|
- ""
|
||||||
|
- config
|
||||||
|
- certs
|
||||||
|
- dumps
|
||||||
|
- images
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"live-restore": true,
|
||||||
|
"log-driver": "json-file",
|
||||||
|
"log-opts": {
|
||||||
|
"max-size": "{{ docker_log_max_size }}",
|
||||||
|
"max-file": "{{ docker_log_max_file }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Managed by Ansible (deploy/ansible).
|
||||||
|
[DEFAULT]
|
||||||
|
bantime = 1h
|
||||||
|
findtime = 10m
|
||||||
|
maxretry = 5
|
||||||
|
backend = systemd
|
||||||
|
|
||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Managed by Ansible (deploy/ansible). Key-only authentication.
|
||||||
|
# root stays reachable by key (prohibit-password) for provisioning re-runs.
|
||||||
|
PasswordAuthentication no
|
||||||
|
PermitRootLogin prohibit-password
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
# Main stack host: public web + bot-link ports and the external 'edge' network
|
||||||
|
# the compose stack attaches caddy to.
|
||||||
|
|
||||||
|
- name: Open public web and bot-link ports
|
||||||
|
community.general.ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "{{ item }}"
|
||||||
|
proto: tcp
|
||||||
|
loop:
|
||||||
|
- "80" # HTTP (ACME challenge + redirect to HTTPS)
|
||||||
|
- "443" # HTTPS (caddy edge)
|
||||||
|
- "9443" # bot-link mTLS (remote bot dials in; mutual TLS gates access)
|
||||||
|
|
||||||
|
- name: Ensure the external 'edge' docker network exists
|
||||||
|
community.docker.docker_network:
|
||||||
|
name: edge
|
||||||
|
state: present
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
# Telegram bot host: holds no inbound port beyond SSH (the bot dials out to the
|
||||||
|
# Bot API and into the main host's bot-link). We only verify direct Bot API
|
||||||
|
# egress here, since the "no VPN" decision depends on it.
|
||||||
|
|
||||||
|
- name: Verify direct Telegram Bot API egress (no VPN on this host)
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: https://api.telegram.org/
|
||||||
|
method: GET
|
||||||
|
status_code: [200, 301, 302, 401, 404] # any HTTP reply proves reachability
|
||||||
|
timeout: 10
|
||||||
|
register: tg_egress
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Report Telegram reachability
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
api.telegram.org reachable:
|
||||||
|
{{ (tg_egress.status | default(0) | int) > 0 }} (status {{ tg_egress.status | default('none') }})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
# Stage 18 host provisioning. Idempotent: safe to re-run after a host resize.
|
||||||
|
# Prepares hosts only (docker, hardening, service account, firewall); the
|
||||||
|
# application is deployed separately by .gitea/workflows/prod-deploy.yaml.
|
||||||
|
|
||||||
|
- name: Common baseline (both hosts)
|
||||||
|
hosts: prod
|
||||||
|
become: true
|
||||||
|
pre_tasks:
|
||||||
|
- name: Require a well-formed CI deploy public key
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- deploy_ci_pubkey | length > 0
|
||||||
|
- deploy_ci_pubkey is search('^(ssh|ecdsa)-')
|
||||||
|
fail_msg: >-
|
||||||
|
deploy_ci_pubkey is empty or malformed. Generate the key first
|
||||||
|
(see deploy/ansible/README.md) or override deploy_ci_pubkey_path.
|
||||||
|
roles:
|
||||||
|
- common
|
||||||
|
|
||||||
|
- name: Main stack host
|
||||||
|
hosts: main
|
||||||
|
become: true
|
||||||
|
roles:
|
||||||
|
- main
|
||||||
|
|
||||||
|
- name: Telegram bot host
|
||||||
|
hosts: tg
|
||||||
|
become: true
|
||||||
|
roles:
|
||||||
|
- tg
|
||||||
Reference in New Issue
Block a user