server_init/playbook.yml
Nelis Volschenk 2236f60469 Add kernel module hardening and Copy Fail (CVE-2026-31431) mitigation
Disable unnecessary kernel modules to reduce attack surface: algif_aead
(Copy Fail), unused filesystems, DMA vectors, unused network protocols,
and USB storage. Rebuild initramfs on change to apply at boot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 12:47:41 +00:00

620 lines
18 KiB
YAML

---
- name: Server Initialization Playbook
hosts: localhost
connection: local
become: yes
vars:
base_domain:
pangolin_domain: "pangolin.{{ base_domain }}"
forgejo_domain: "forgejo.{{ base_domain }}"
muxplex_domain: "muxplex.{{ base_domain }}"
nextcloud_domain: "nextcloud.{{ base_domain }}"
acme_email: "admin@{{ base_domain }}"
docker_hosting_dir: "/home/{{ login_user }}/docker_hosting"
resources_dir: "{{ playbook_dir }}/resources"
login_user: "{{ ansible_env.SUDO_USER | default(ansible_user_id) }}"
deb_architecture: "{{ 'amd64' if ansible_architecture == 'x86_64' else 'arm64' if ansible_architecture == 'aarch64' else ansible_architecture }}"
ssh_pubkey: ""
juicefs_s3_endpoint: ""
juicefs_s3_bucket: ""
juicefs_s3_access_key: ""
juicefs_s3_secret_key: ""
juicefs_cache_size: "50G"
tasks:
- name: Update apt cache
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600
- name: Install prerequisites for Docker repository
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
- lsb-release
state: present
- name: Install common dev dependencies
ansible.builtin.apt:
name:
- git
- vim
- wget
- htop
- tmux
- unzip
- zip
- build-essential
- gcc
- make
- pkg-config
- libssl-dev
- libffi-dev
- python3
- python3-pip
- python3-venv
- jq
- ttyd
state: present
# --- Kernel module hardening ---
- name: Disable unnecessary and vulnerable kernel modules
ansible.builtin.copy:
dest: /etc/modprobe.d/hardening.conf
mode: '0644'
content: |
# CVE-2026-31431 (Copy Fail) - AF_ALG AEAD privilege escalation
install algif_aead /bin/false
# Unused filesystems
install cramfs /bin/false
install freevxfs /bin/false
install jffs2 /bin/false
install hfs /bin/false
install hfsplus /bin/false
install squashfs /bin/false
install udf /bin/false
# DMA attack vectors (firewire / thunderbolt)
install firewire-core /bin/false
install thunderbolt /bin/false
# Unused network protocols
install dccp /bin/false
install sctp /bin/false
install rds /bin/false
install tipc /bin/false
# USB storage (not needed on headless server)
install usb-storage /bin/false
notify: update initramfs
- name: Unload algif_aead module if currently loaded
ansible.builtin.shell:
cmd: rmmod algif_aead 2>/dev/null || true
changed_when: false
# --- Server hardening ---
- name: Install security packages
ansible.builtin.apt:
name:
- ufw
- fail2ban
- unattended-upgrades
- apt-listchanges
state: present
- name: Disable root SSH login
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin no'
validate: 'sshd -t -f %s'
notify: restart sshd
- name: Disable SSH password authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PasswordAuthentication'
line: 'PasswordAuthentication no'
validate: 'sshd -t -f %s'
notify: restart sshd
- name: Disable SSH challenge-response authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?KbdInteractiveAuthentication'
line: 'KbdInteractiveAuthentication no'
validate: 'sshd -t -f %s'
notify: restart sshd
- name: Set SSH MaxAuthTries
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?MaxAuthTries'
line: 'MaxAuthTries 3'
validate: 'sshd -t -f %s'
notify: restart sshd
- name: UFW - Allow forwarding for Docker
ansible.builtin.lineinfile:
path: /etc/default/ufw
regexp: '^DEFAULT_FORWARD_POLICY='
line: 'DEFAULT_FORWARD_POLICY="ACCEPT"'
- name: UFW - Set default deny incoming
community.general.ufw:
direction: incoming
default: deny
- name: UFW - Set default allow outgoing
community.general.ufw:
direction: outgoing
default: allow
- name: UFW - Allow SSH
community.general.ufw:
rule: allow
port: '22'
proto: tcp
- name: UFW - Allow HTTP
community.general.ufw:
rule: allow
port: '80'
proto: tcp
- name: UFW - Allow HTTPS
community.general.ufw:
rule: allow
port: '443'
proto: tcp
- name: UFW - Allow WireGuard
community.general.ufw:
rule: allow
port: '51820'
proto: udp
- name: UFW - Allow WireGuard clients
community.general.ufw:
rule: allow
port: '21820'
proto: udp
- name: UFW - Allow Docker to reach muxplex on host
community.general.ufw:
rule: allow
from_ip: 172.18.0.0/16
to_ip: any
port: '8088'
proto: tcp
- name: UFW - Enable firewall
community.general.ufw:
state: enabled
- name: Configure fail2ban for SSH
ansible.builtin.copy:
dest: /etc/fail2ban/jail.local
mode: '0644'
content: |
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
- name: Start and enable fail2ban
ansible.builtin.service:
name: fail2ban
state: started
enabled: yes
- name: Enable unattended security 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";
APT::Periodic::AutocleanInterval "7";
- name: Create keyrings directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
- name: Add Docker GPG key
ansible.builtin.shell:
cmd: curl -fsSL https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg -o /etc/apt/keyrings/docker.asc
args:
creates: /etc/apt/keyrings/docker.asc
- name: Add Docker repository
ansible.builtin.apt_repository:
repo: "deb [arch={{ deb_architecture }} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable"
state: present
- name: Install Docker
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: yes
- name: Start and enable Docker
ansible.builtin.service:
name: docker
state: started
enabled: yes
- name: Add user to docker group
ansible.builtin.user:
name: "{{ login_user }}"
groups: docker
append: yes
- name: Install Rust via rustup for login user
ansible.builtin.shell:
cmd: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
args:
creates: "/home/{{ login_user }}/.cargo/bin/rustup"
become_user: "{{ login_user }}"
- name: Install uv (Python package manager) for login user
ansible.builtin.shell:
cmd: curl -LsSf https://astral.sh/uv/install.sh | sh
args:
creates: "/home/{{ login_user }}/.local/bin/uv"
become_user: "{{ login_user }}"
# --- Forgejo SSH passthrough ---
- name: Create forgejo-shell directory
ansible.builtin.file:
path: /etc/forgejo/bin
state: directory
mode: '0755'
- name: Create forgejo-shell script
ansible.builtin.copy:
dest: /etc/forgejo/bin/forgejo-shell
mode: '0755'
content: |
#!/bin/sh
/usr/bin/docker exec -i -u git --env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" forgejo sh "$@"
- name: Create git user for Forgejo
ansible.builtin.user:
name: git
system: yes
shell: /etc/forgejo/bin/forgejo-shell
home: /home/git
create_home: yes
register: git_user
- name: Set git user UID/GID facts
ansible.builtin.set_fact:
git_uid: "{{ git_user.uid }}"
git_gid: "{{ git_user.group }}"
- name: Add git user to docker group
ansible.builtin.user:
name: git
groups: docker
append: yes
- name: Configure SSH passthrough for git user
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/forgejo-ssh-passthrough.conf
mode: '0644'
content: |
Match User git
AuthorizedKeysCommandUser git
AuthorizedKeysCommand /usr/bin/docker exec -i -u git forgejo /usr/local/bin/gitea keys -c /data/gitea/conf/app.ini -e git -u %u -t %t -k %k
notify: restart sshd
- name: Generate Pangolin secret
ansible.builtin.shell:
cmd: openssl rand -base64 48
register: pangolin_secret_result
changed_when: false
- name: Set Pangolin secret fact
ansible.builtin.set_fact:
pangolin_secret: "{{ pangolin_secret_result.stdout }}"
- name: Generate Nextcloud admin password
ansible.builtin.shell:
cmd: openssl rand -base64 24
register: nextcloud_admin_pass_result
changed_when: false
- name: Generate Nextcloud DB password
ansible.builtin.shell:
cmd: openssl rand -base64 24
register: nextcloud_db_pass_result
changed_when: false
- name: Set Nextcloud password facts
ansible.builtin.set_fact:
nextcloud_admin_pass: "{{ nextcloud_admin_pass_result.stdout }}"
nextcloud_db_pass: "{{ nextcloud_db_pass_result.stdout }}"
# --- Directory structure ---
- name: Create docker_hosting directory structure
ansible.builtin.file:
path: "{{ docker_hosting_dir }}/{{ item }}"
state: directory
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0755'
loop:
- docker_data/pangolin
- docker_data/pangolin/db
- docker_data/pangolin/letsencrypt
- docker_data/pangolin/logs
- docker_data/pangolin/traefik
- docker_data/pangolin/traefik/logs
- docker_data/nextcloud
- docker_data/nextcloud/db
- docker_data/nextcloud/redis
- name: Create Forgejo data directory
ansible.builtin.file:
path: "{{ docker_hosting_dir }}/docker_data/forgejo/data"
state: directory
owner: git
group: git
mode: '0755'
# --- Pangolin config files ---
- name: Copy Pangolin config.yml
ansible.builtin.template:
src: "{{ resources_dir }}/pangolin/config.yml.j2"
dest: "{{ docker_hosting_dir }}/docker_data/pangolin/config.yml"
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0600'
force: no
- name: Copy Traefik static config
ansible.builtin.template:
src: "{{ resources_dir }}/pangolin/traefik_config.yml.j2"
dest: "{{ docker_hosting_dir }}/docker_data/pangolin/traefik/traefik_config.yml"
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0644'
- name: Copy Traefik dynamic config
ansible.builtin.template:
src: "{{ resources_dir }}/pangolin/dynamic_config.yml.j2"
dest: "{{ docker_hosting_dir }}/docker_data/pangolin/traefik/dynamic_config.yml"
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0644'
- name: Copy compose.yml
ansible.builtin.template:
src: "{{ resources_dir }}/compose.yml.j2"
dest: "{{ docker_hosting_dir }}/compose.yml"
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0644'
# --- aicoder user setup ---
- name: Create aicoder user
ansible.builtin.user:
name: aicoder
shell: /bin/bash
home: /home/aicoder
create_home: yes
- name: Create aicoder directories
ansible.builtin.file:
path: "/home/aicoder/{{ item }}"
state: directory
owner: aicoder
group: aicoder
mode: '0755'
loop:
- codeprojects
- scripts
- .ssh
- name: Add SSH key for aicoder
ansible.builtin.copy:
dest: /home/aicoder/.ssh/authorized_keys
content: "{{ ssh_pubkey }}\n"
owner: aicoder
group: aicoder
mode: '0600'
when: ssh_pubkey | length > 0
- name: Set aicoder .ssh directory permissions
ansible.builtin.file:
path: /home/aicoder/.ssh
mode: '0700'
owner: aicoder
group: aicoder
- name: Install nvm for aicoder
ansible.builtin.shell:
cmd: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
args:
creates: /home/aicoder/.nvm/nvm.sh
become_user: aicoder
- name: Install latest LTS Node.js via nvm
ansible.builtin.shell:
cmd: bash -c 'source ~/.nvm/nvm.sh && nvm install --lts'
args:
creates: /home/aicoder/.nvm/alias/lts
become_user: aicoder
- name: Install Claude Code via nvm npm
ansible.builtin.shell:
cmd: bash -c 'source ~/.nvm/nvm.sh && npm install -g @anthropic-ai/claude-code'
become_user: aicoder
args:
creates: /home/aicoder/.nvm/versions/node
- name: Install pi-coding-agent via nvm npm
ansible.builtin.shell:
cmd: bash -c 'source ~/.nvm/nvm.sh && npm install -g @mariozechner/pi-coding-agent'
become_user: aicoder
- name: Install uv for aicoder
ansible.builtin.shell:
cmd: curl -LsSf https://astral.sh/uv/install.sh | sh
args:
creates: /home/aicoder/.local/bin/uv
become_user: aicoder
- name: Install muxplex for aicoder
ansible.builtin.shell:
cmd: /home/aicoder/.local/bin/uv tool install git+https://github.com/bkrabach/muxplex
become_user: aicoder
args:
creates: /home/aicoder/.local/bin/muxplex
- name: Configure muxplex to bind to all interfaces
ansible.builtin.shell:
cmd: /home/aicoder/.local/bin/muxplex config set host 0.0.0.0
become_user: aicoder
- name: Configure muxplex auth to none (Pangolin handles auth)
ansible.builtin.shell:
cmd: /home/aicoder/.local/bin/muxplex config set auth none
become_user: aicoder
- name: Install muxplex as systemd service
ansible.builtin.shell:
cmd: /home/aicoder/.local/bin/muxplex service install
become_user: aicoder
- name: Enable and start muxplex service
ansible.builtin.systemd:
name: muxplex
state: started
enabled: yes
scope: user
become_user: aicoder
- name: Enable lingering for aicoder (keep user services running)
ansible.builtin.shell:
cmd: loginctl enable-linger aicoder
args:
creates: /var/lib/systemd/linger/aicoder
# --- Cron cleanup script ---
- name: Create build artifact cleanup script
ansible.builtin.copy:
dest: /home/aicoder/scripts/cleanup-build-artifacts.sh
owner: aicoder
group: aicoder
mode: '0755'
content: |
#!/bin/bash
PROJECTS_DIR="/home/aicoder/codeprojects"
MAX_AGE_DAYS=4
# Rust - remove target directories
find "$PROJECTS_DIR" -maxdepth 3 -type d -name "target" -mtime +$MAX_AGE_DAYS -exec rm -rf {} +
# Node - remove node_modules directories
find "$PROJECTS_DIR" -maxdepth 3 -type d -name "node_modules" -mtime +$MAX_AGE_DAYS -exec rm -rf {} +
- name: Set up cron job for build artifact cleanup
ansible.builtin.cron:
name: "cleanup build artifacts"
user: aicoder
minute: "0"
hour: "3"
job: "/home/aicoder/scripts/cleanup-build-artifacts.sh"
# --- JuiceFS + Nextcloud directories ---
- name: Create JuiceFS and Nextcloud data directories
ansible.builtin.file:
path: "{{ docker_hosting_dir }}/{{ item }}"
state: directory
owner: "{{ login_user }}"
group: "{{ login_user }}"
mode: '0755'
loop:
- docker_data/nextcloud/data
- docker_data/juicefs/cache
# --- Start infrastructure services (Redis needs to be up before JuiceFS format) ---
- name: Start Redis via docker compose
ansible.builtin.shell:
cmd: docker compose up -d redis
chdir: "{{ docker_hosting_dir }}"
- name: Wait for Redis to be ready
ansible.builtin.shell:
cmd: docker exec redis redis-cli ping
register: redis_ping
until: redis_ping.stdout == "PONG"
retries: 10
delay: 3
- name: Format JuiceFS filesystem
ansible.builtin.shell:
cmd: >
docker run --rm --network pangolin
juicedata/mount:ce-v1
juicefs format
--storage s3
--bucket {{ juicefs_s3_endpoint }}/{{ juicefs_s3_bucket }}
--access-key {{ juicefs_s3_access_key }}
--secret-key {{ juicefs_s3_secret_key }}
"redis://redis:6379/1"
nextcloud-data
args:
creates: /etc/juicefs-formatted
- name: Mark JuiceFS as formatted
ansible.builtin.file:
path: /etc/juicefs-formatted
state: touch
mode: '0644'
# --- Start all services ---
- name: Start all services via docker compose
ansible.builtin.shell:
cmd: docker compose up -d
chdir: "{{ docker_hosting_dir }}"
handlers:
- name: restart sshd
ansible.builtin.service:
name: ssh
state: restarted
- name: update initramfs
ansible.builtin.command:
cmd: update-initramfs -u