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,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') }})
|
||||
Reference in New Issue
Block a user