c16008e67a
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
The tg (bot) VPS came up on Europe/Moscow while the main host is UTC, so host-level timestamps (journald, file mtimes, cron) sat 3h apart across the fleet. Add a community.general.timezone task to the common role so every provisioned host is UTC (idempotent — a no-op on the already-UTC main host). Applied live to the tg host in the same change; the containerised services run in UTC regardless, so no restart.
240 lines
7.3 KiB
YAML
240 lines
7.3 KiB
YAML
---
|
|
# 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
|
|
|
|
# Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up
|
|
# across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The
|
|
# services themselves run in UTC regardless; this is about host-side log correlation.
|
|
- name: Set the system timezone to UTC
|
|
community.general.timezone:
|
|
name: Etc/UTC
|
|
|
|
# --- Swap file (OOM cushion) ---------------------------------------------------
|
|
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
|
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
|
# the kernel OOM-killer (which might pick postgres). A small swap absorbs the
|
|
# overshoot. Idempotent; skipped when swap_size == "0". Builtin-only (no ansible.posix).
|
|
|
|
- name: Check whether the swap file is already active
|
|
ansible.builtin.command: swapon --show=NAME --noheadings
|
|
register: swap_active
|
|
changed_when: false
|
|
when: swap_size != "0"
|
|
|
|
- name: Allocate the swap file
|
|
ansible.builtin.command:
|
|
cmd: "fallocate -l {{ swap_size }} /swapfile"
|
|
creates: /swapfile
|
|
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
|
|
|
|
- name: Secure the swap file (0600)
|
|
ansible.builtin.file:
|
|
path: /swapfile
|
|
owner: root
|
|
group: root
|
|
mode: "0600"
|
|
when: swap_size != "0"
|
|
|
|
- name: Format and enable the swap file
|
|
ansible.builtin.shell: "mkswap /swapfile && swapon /swapfile"
|
|
when: swap_size != "0" and '/swapfile' not in swap_active.stdout
|
|
|
|
- name: Persist the swap file in /etc/fstab
|
|
ansible.builtin.lineinfile:
|
|
path: /etc/fstab
|
|
line: "/swapfile none swap sw 0 0"
|
|
regexp: '^/swapfile\s'
|
|
state: present
|
|
when: swap_size != "0"
|
|
|
|
- name: Keep swap a cushion, not a hot path (low swappiness)
|
|
ansible.builtin.copy:
|
|
dest: /etc/sysctl.d/60-scrabble-swap.conf
|
|
mode: "0644"
|
|
content: "vm.swappiness = {{ swap_swappiness }}\n"
|
|
register: swappiness_conf
|
|
when: swap_size != "0"
|
|
|
|
- name: Apply swappiness now
|
|
ansible.builtin.command: sysctl --system
|
|
changed_when: false
|
|
when: swap_size != "0" and swappiness_conf is changed
|
|
|
|
# --- 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
|
|
- dumps
|
|
- images
|
|
|
|
# The certs dir holds the reverse-mTLS bot-link keypair, bind-mounted into the gateway (and
|
|
# backend) which run as the distroless nonroot UID 65532 — not the deploy user. The dir must be
|
|
# traversable by "other" (0755) or the nonroot process cannot open the 0644 keypair and crash-loops
|
|
# at startup ("mtls: load server keypair: ... permission denied") — a latent failure that only bites
|
|
# on a container restart (e.g. a host reboot), not while a long-running container holds the keypair
|
|
# in memory. The keys themselves are 0644 by design (see the gateway compose); the host is
|
|
# single-tenant and SSH-access-controlled, so a traversable certs dir adds no meaningful exposure.
|
|
- name: Create the scrabble certs directory (traversable by the nonroot gateway UID)
|
|
ansible.builtin.file:
|
|
path: "{{ scrabble_base_dir }}/certs"
|
|
state: directory
|
|
owner: "{{ deploy_user }}"
|
|
group: "{{ deploy_user }}"
|
|
mode: "0755"
|