From 5ec9a538447fcd45b5a146dd70b1e0429ed63fa8 Mon Sep 17 00:00:00 2001 From: NetworkCats Date: Thu, 5 Mar 2026 21:12:50 +0800 Subject: [PATCH] feat: add deployment infrastructure with Terraform, Ansible, and GitHub Actions --- .github/workflows/deploy.yml | 220 ++++++++++++++++++ .gitignore | 14 ++ deploy/ansible/playbook.yml | 22 ++ deploy/ansible/roles/app/handlers/main.yml | 5 + deploy/ansible/roles/app/tasks/main.yml | 194 +++++++++++++++ .../roles/app/templates/docker-compose.yml.j2 | 66 ++++++ deploy/ansible/roles/app/templates/env.j2 | 5 + .../roles/app/templates/haproxy.cfg.j2 | 77 ++++++ deploy/ansible/roles/base/handlers/main.yml | 9 + deploy/ansible/roles/base/tasks/main.yml | 106 +++++++++ .../base/templates/fail2ban-sshd.conf.j2 | 14 ++ .../roles/base/templates/limits.conf.j2 | 5 + .../roles/base/templates/sysctl.conf.j2 | 59 +++++ deploy/ansible/roles/docker/handlers/main.yml | 9 + deploy/ansible/roles/docker/tasks/main.yml | 69 ++++++ .../roles/docker/templates/daemon.json.j2 | 21 ++ deploy/terraform/firewall.tf | 26 +++ deploy/terraform/instance.tf | 15 ++ deploy/terraform/main.tf | 28 +++ deploy/terraform/outputs.tf | 10 + deploy/terraform/ssh_key.tf | 4 + deploy/terraform/variables.tf | 34 +++ 22 files changed, 1012 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 deploy/ansible/playbook.yml create mode 100644 deploy/ansible/roles/app/handlers/main.yml create mode 100644 deploy/ansible/roles/app/tasks/main.yml create mode 100644 deploy/ansible/roles/app/templates/docker-compose.yml.j2 create mode 100644 deploy/ansible/roles/app/templates/env.j2 create mode 100644 deploy/ansible/roles/app/templates/haproxy.cfg.j2 create mode 100644 deploy/ansible/roles/base/handlers/main.yml create mode 100644 deploy/ansible/roles/base/tasks/main.yml create mode 100644 deploy/ansible/roles/base/templates/fail2ban-sshd.conf.j2 create mode 100644 deploy/ansible/roles/base/templates/limits.conf.j2 create mode 100644 deploy/ansible/roles/base/templates/sysctl.conf.j2 create mode 100644 deploy/ansible/roles/docker/handlers/main.yml create mode 100644 deploy/ansible/roles/docker/tasks/main.yml create mode 100644 deploy/ansible/roles/docker/templates/daemon.json.j2 create mode 100644 deploy/terraform/firewall.tf create mode 100644 deploy/terraform/instance.tf create mode 100644 deploy/terraform/main.tf create mode 100644 deploy/terraform/outputs.tf create mode 100644 deploy/terraform/ssh_key.tf create mode 100644 deploy/terraform/variables.tf diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..de2858b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,220 @@ +name: Deploy + +on: + push: + tags: + - "v*" + +env: + CARGO_TERM_COLOR: always + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +permissions: + contents: read + packages: write + +jobs: + # ---- CI checks ---- + check: + name: Check + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.93.1" + components: rustfmt, clippy + + - name: Cache Cargo artifacts + uses: Swatinem/rust-cache@v2 + + - name: Download MMDB database + run: | + mkdir -p data + curl -fsSL -o data/Merged-IP.mmdb \ + "https://github.com/NetworkCats/Merged-IP-Data/releases/latest/download/Merged-IP.mmdb" + + - name: Check formatting + run: cargo fmt --check + + - name: Run Clippy + run: cargo clippy -- -D warnings + + - name: Run tests + run: cargo test + + # ---- Build and push Docker image ---- + build: + name: Build Image + runs-on: ubuntu-latest + needs: check + outputs: + image_tag: ${{ steps.meta.outputs.tags }} + image_full: ${{ steps.full_image.outputs.image }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Set full image reference + id: full_image + run: | + TAG="${GITHUB_REF_NAME#v}" + echo "image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}" >> "$GITHUB_OUTPUT" + + # ---- Deploy to VPS ---- + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: build + environment: production + concurrency: + group: deploy-production + cancel-in-progress: false + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # ---- Terraform: provision infrastructure ---- + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + + - name: Terraform init + working-directory: deploy/terraform + env: + AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_KEY }} + run: | + terraform init \ + -backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}" \ + -backend-config="endpoints={s3=\"${{ secrets.TF_STATE_ENDPOINT }}\"}" + + - name: Terraform apply + working-directory: deploy/terraform + env: + AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_KEY }} + run: | + terraform apply -auto-approve \ + -var="vultr_api_key=${{ secrets.VULTR_API_KEY }}" \ + -var="ssh_public_key=${{ secrets.SSH_PUBLIC_KEY }}" + + - name: Extract VPS IP (masked) + id: vps + working-directory: deploy/terraform + env: + AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_KEY }} + run: | + VPS_IP=$(terraform output -raw instance_ip) + echo "::add-mask::${VPS_IP}" + echo "ip=${VPS_IP}" >> "$GITHUB_OUTPUT" + + # ---- SSH setup ---- + - name: Setup SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "${{ steps.vps.outputs.ip }}" >> ~/.ssh/known_hosts 2>/dev/null || true + + # ---- Detect first deployment ---- + - name: Detect first deployment + id: detect + run: | + set +e + ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \ + -i ~/.ssh/deploy_key root@"${{ steps.vps.outputs.ip }}" \ + "test -f /opt/rustyip/.active_color" + if [ $? -ne 0 ]; then + echo "first_deploy=true" >> "$GITHUB_OUTPUT" + else + echo "first_deploy=false" >> "$GITHUB_OUTPUT" + fi + + # ---- Ansible: configure VPS and deploy ---- + - name: Install Ansible + run: pip install ansible + + - name: Generate Ansible inventory + run: | + cat > deploy/ansible/inventory.yml </dev/null) + if [ "$status" = "healthy" ]; then + echo "Deployment verified: app-${DEPLOY_COLOR} is healthy" + exit 0 + fi + echo "Waiting for health check... (attempt $i/15)" + sleep 4 + done + echo "Health check failed after 60 seconds" + exit 1 + + # ---- Cleanup ---- + - name: Cleanup SSH key + if: always() + run: rm -f ~/.ssh/deploy_key diff --git a/.gitignore b/.gitignore index c1444fa..707328b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,20 @@ data/ .env haproxy/certs/ +# Terraform +.terraform/ +*.tfstate +*.tfstate.backup +*.tfvars +.terraform.lock.hcl +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ansible +deploy/ansible/inventory.yml +*.retry # Added by cargo diff --git a/deploy/ansible/playbook.yml b/deploy/ansible/playbook.yml new file mode 100644 index 0000000..8fdfa0b --- /dev/null +++ b/deploy/ansible/playbook.yml @@ -0,0 +1,22 @@ +--- +- name: Configure RustyIP VPS + hosts: vps + become: true + gather_facts: true + + vars: + app_image: "{{ lookup('env', 'APP_IMAGE') }}" + site_domain: "{{ lookup('env', 'SITE_DOMAIN') }}" + db_update_url: "{{ lookup('env', 'DB_UPDATE_URL') }}" + origin_cert: "{{ lookup('env', 'ORIGIN_CERT') }}" + origin_key: "{{ lookup('env', 'ORIGIN_KEY') }}" + ghcr_token: "{{ lookup('env', 'GHCR_TOKEN') }}" + ghcr_user: "{{ lookup('env', 'GHCR_USER') }}" + first_deploy: "{{ lookup('env', 'FIRST_DEPLOY') | default('false', true) }}" + + roles: + - role: base + when: first_deploy | bool + - role: docker + when: first_deploy | bool + - role: app diff --git a/deploy/ansible/roles/app/handlers/main.yml b/deploy/ansible/roles/app/handlers/main.yml new file mode 100644 index 0000000..27608ce --- /dev/null +++ b/deploy/ansible/roles/app/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload haproxy + ansible.builtin.shell: | + docker compose -f /opt/rustyip/docker-compose.yml kill -s HUP haproxy + failed_when: false diff --git a/deploy/ansible/roles/app/tasks/main.yml b/deploy/ansible/roles/app/tasks/main.yml new file mode 100644 index 0000000..92d69fc --- /dev/null +++ b/deploy/ansible/roles/app/tasks/main.yml @@ -0,0 +1,194 @@ +--- +- name: Create application directory + ansible.builtin.file: + path: /opt/rustyip + state: directory + owner: root + group: root + mode: "0755" + +- name: Create HAProxy directory + ansible.builtin.file: + path: /opt/rustyip/haproxy + state: directory + owner: root + group: root + mode: "0755" + +- name: Create TLS certificate directory + ansible.builtin.file: + path: /opt/rustyip/haproxy/certs + state: directory + owner: root + group: root + mode: "0700" + +- name: Deploy TLS origin certificate + ansible.builtin.copy: + content: "{{ origin_cert }}\n{{ origin_key }}" + dest: /opt/rustyip/haproxy/certs/origin.pem + owner: root + group: root + mode: "0600" + notify: Reload haproxy + +- name: Deploy environment file + ansible.builtin.template: + src: env.j2 + dest: /opt/rustyip/.env + owner: root + group: root + mode: "0600" + +- name: Log in to GHCR + ansible.builtin.shell: | + echo "{{ ghcr_token }}" | docker login ghcr.io -u "{{ ghcr_user }}" --password-stdin + no_log: true + changed_when: true + +- name: Pull application image + ansible.builtin.shell: | + docker pull "{{ app_image }}" + changed_when: true + +# --- Determine deployment state --- + +- name: Check for existing active color state file + ansible.builtin.stat: + path: /opt/rustyip/.active_color + register: active_color_stat + +- name: Read active color from state file + ansible.builtin.slurp: + src: /opt/rustyip/.active_color + register: active_color_file + when: active_color_stat.stat.exists + +- name: Set color facts for existing deployment + ansible.builtin.set_fact: + current_active: "{{ (active_color_file.content | b64decode | trim) }}" + deploy_target: "{{ 'green' if (active_color_file.content | b64decode | trim) == 'blue' else 'blue' }}" + is_first_deploy: false + when: active_color_stat.stat.exists + +- name: Set color facts for first deployment + ansible.builtin.set_fact: + current_active: "" + deploy_target: "blue" + is_first_deploy: true + when: not active_color_stat.stat.exists + +# --- Deploy docker-compose and HAProxy config --- + +- name: Deploy docker-compose.yml + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: /opt/rustyip/docker-compose.yml + owner: root + group: root + mode: "0644" + +- name: Deploy HAProxy configuration (target color active) + ansible.builtin.template: + src: haproxy.cfg.j2 + dest: /opt/rustyip/haproxy/haproxy.cfg + owner: root + group: root + mode: "0644" + vars: + active_color: "{{ deploy_target }}" + +# --- First Deployment --- + +- name: Start target app container + ansible.builtin.shell: | + docker compose -f /opt/rustyip/docker-compose.yml up -d "app-{{ deploy_target }}" + changed_when: true + when: is_first_deploy + +- name: Wait for target container health check (first deploy) + ansible.builtin.shell: | + for i in $(seq 1 30); do + status=$(docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' "rustyip-app-{{ deploy_target }}" 2>/dev/null) + if [ "$status" = "healthy" ]; then + echo "healthy" + exit 0 + fi + sleep 2 + done + echo "timeout" + exit 1 + changed_when: false + when: is_first_deploy + +- name: Start HAProxy (first deploy) + ansible.builtin.shell: | + docker compose -f /opt/rustyip/docker-compose.yml up -d haproxy + changed_when: true + when: is_first_deploy + +- name: Write active color state file (first deploy) + ansible.builtin.copy: + content: "{{ deploy_target }}" + dest: /opt/rustyip/.active_color + owner: root + group: root + mode: "0644" + when: is_first_deploy + +# --- Blue-Green Deployment --- + +- name: Start standby container with new image + ansible.builtin.shell: | + docker compose -f /opt/rustyip/docker-compose.yml up -d "app-{{ deploy_target }}" + changed_when: true + when: not is_first_deploy + +- name: Wait for standby container health check + ansible.builtin.shell: | + for i in $(seq 1 30); do + status=$(docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' "rustyip-app-{{ deploy_target }}" 2>/dev/null) + if [ "$status" = "healthy" ]; then + echo "healthy" + exit 0 + fi + sleep 2 + done + echo "timeout" + exit 1 + register: health_result + changed_when: false + when: not is_first_deploy + +- name: Reload HAProxy to switch to new backend + ansible.builtin.shell: | + docker compose -f /opt/rustyip/docker-compose.yml up -d haproxy + changed_when: true + when: not is_first_deploy + +- name: Wait for HAProxy to settle + ansible.builtin.pause: + seconds: 5 + when: not is_first_deploy + +- name: Stop old active container + ansible.builtin.shell: | + docker compose -f /opt/rustyip/docker-compose.yml stop "app-{{ current_active }}" + docker compose -f /opt/rustyip/docker-compose.yml rm -f "app-{{ current_active }}" + changed_when: true + when: not is_first_deploy + +- name: Update active color state file + ansible.builtin.copy: + content: "{{ deploy_target }}" + dest: /opt/rustyip/.active_color + owner: root + group: root + mode: "0644" + when: not is_first_deploy + +- name: Clean up unused Docker images + ansible.builtin.shell: | + docker image prune -af --filter "until=1h" + changed_when: true + failed_when: false diff --git a/deploy/ansible/roles/app/templates/docker-compose.yml.j2 b/deploy/ansible/roles/app/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..95cf2e7 --- /dev/null +++ b/deploy/ansible/roles/app/templates/docker-compose.yml.j2 @@ -0,0 +1,66 @@ +services: + haproxy: + image: haproxy:3.3.4-alpine3.23 + container_name: rustyip-haproxy + ports: + - "443:443" + volumes: + - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + - ./haproxy/certs:/etc/haproxy/certs:ro + restart: unless-stopped + ulimits: + nofile: + soft: 1048576 + hard: 1048576 + sysctls: + net.core.somaxconn: "65535" + net.ipv4.tcp_tw_reuse: "1" + networks: + - internal + + app-blue: + image: {{ app_image }} + container_name: rustyip-app-blue + env_file: .env + volumes: + - db_data:/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health || exit 1"] + interval: 5s + timeout: 3s + retries: 3 + start_period: 15s + restart: unless-stopped + ulimits: + nofile: + soft: 1048576 + hard: 1048576 + networks: + - internal + + app-green: + image: {{ app_image }} + container_name: rustyip-app-green + env_file: .env + volumes: + - db_data:/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health || exit 1"] + interval: 5s + timeout: 3s + retries: 3 + start_period: 15s + restart: unless-stopped + ulimits: + nofile: + soft: 1048576 + hard: 1048576 + networks: + - internal + +volumes: + db_data: + +networks: + internal: + driver: bridge diff --git a/deploy/ansible/roles/app/templates/env.j2 b/deploy/ansible/roles/app/templates/env.j2 new file mode 100644 index 0000000..8cbcf83 --- /dev/null +++ b/deploy/ansible/roles/app/templates/env.j2 @@ -0,0 +1,5 @@ +LISTEN_ADDR=0.0.0.0:3000 +DB_PATH=/data/Merged-IP.mmdb +DB_UPDATE_URL={{ db_update_url }} +DB_UPDATE_INTERVAL_HOURS=24 +SITE_DOMAIN={{ site_domain }} diff --git a/deploy/ansible/roles/app/templates/haproxy.cfg.j2 b/deploy/ansible/roles/app/templates/haproxy.cfg.j2 new file mode 100644 index 0000000..c26569d --- /dev/null +++ b/deploy/ansible/roles/app/templates/haproxy.cfg.j2 @@ -0,0 +1,77 @@ +global + log stderr local0 err + maxconn 100000 + # Performance tuning for high-throughput + tune.bufsize 16384 + tune.maxrewrite 1024 + tune.http.maxhdr 128 + tune.comp.maxlevel 1 + tune.ssl.default-dh-param 2048 + # TLS configuration optimized for Cloudflare-to-origin connections + ssl-default-bind-curves X25519:prime256v1:secp384r1 + ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384 + ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 + ssl-default-bind-options prefer-client-ciphers ssl-min-ver TLSv1.2 no-tls-tickets + +defaults + log global + mode http + option httplog + option dontlognormal + option http-keep-alive + maxconn 100000 + timeout connect 5s + timeout client 30s + timeout server 30s + timeout http-request 10s + timeout http-keep-alive 60s + timeout queue 30s + +frontend https_in + bind *:443 ssl crt /etc/haproxy/certs/origin.pem alpn h2,http/1.1 strict-sni + + # Gzip compression + compression algo gzip + compression type text/html text/plain application/json text/css + + # Access to port 443 is already restricted to Cloudflare IPs at the firewall level + # All requests reaching HAProxy come from Cloudflare; no additional ACL validation is required. + + # HAProxy's own forwardfor must be disabled, otherwise it will insert Cloudflare node IPs + no option forwardfor + + # Overwrite standard headers directly with the real IP forcibly added by Cloudflare + http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] + http-request set-header X-Real-IP %[req.hdr(CF-Connecting-IP)] + + # Rate limiting: tiered based on global request rate + # Track per-IP request rate (per minute) + stick-table type string len 45 size 500k expire 1m store http_req_rate(1m) + http-request track-sc0 req.hdr(CF-Connecting-IP) + # Track global request rate (per second) + http-request track-sc1 str(global) table global_rate_tracking + + # Tier 3: global >= 200 RPS -> limit each IP to 5 RPM + http-request deny deny_status 429 if { sc_http_req_rate(1,global_rate_tracking) ge 200 } { sc_http_req_rate(0) gt 5 } + # Tier 2: global >= 100 RPS -> limit each IP to 10 RPM + http-request deny deny_status 429 if { sc_http_req_rate(1,global_rate_tracking) ge 100 } { sc_http_req_rate(0) gt 10 } + # Tier 1: default -> limit each IP to 30 RPM + http-request deny deny_status 429 if { sc_http_req_rate(0) gt 30 } + + # Block /health from external access + acl is_health path /health + http-request deny deny_status 403 if is_health + + default_backend app + +backend global_rate_tracking + stick-table type string len 6 size 1 expire 10m store http_req_rate(1s) + +backend app + balance roundrobin + option httpchk GET /health + http-check expect status 200 + + # Blue-green backends: only the active color is enabled + server blue app-blue:3000 check inter 2s fall 3 rise 2 {{ 'disabled' if active_color == 'green' else '' }} + server green app-green:3000 check inter 2s fall 3 rise 2 {{ 'disabled' if active_color == 'blue' else '' }} diff --git a/deploy/ansible/roles/base/handlers/main.yml b/deploy/ansible/roles/base/handlers/main.yml new file mode 100644 index 0000000..d5d441f --- /dev/null +++ b/deploy/ansible/roles/base/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Restart fail2ban + ansible.builtin.systemd: + name: fail2ban + state: restarted + +- name: Reload sysctl + ansible.builtin.command: sysctl --system + changed_when: true diff --git a/deploy/ansible/roles/base/tasks/main.yml b/deploy/ansible/roles/base/tasks/main.yml new file mode 100644 index 0000000..1c1b818 --- /dev/null +++ b/deploy/ansible/roles/base/tasks/main.yml @@ -0,0 +1,106 @@ +--- +- name: Wait for cloud-init to finish + ansible.builtin.command: cloud-init status --wait + changed_when: false + failed_when: false + +- name: Update apt cache and upgrade all packages + ansible.builtin.apt: + update_cache: true + upgrade: dist + cache_valid_time: 3600 + +- name: Install essential packages + ansible.builtin.apt: + name: + - ufw + - fail2ban + - curl + - wget + - htop + - unattended-upgrades + - apt-transport-https + - ca-certificates + - gnupg + - lsb-release + - iptables + state: present + +# --- UFW Configuration --- + +- name: Set UFW default policy - deny incoming + community.general.ufw: + direction: incoming + default: deny + +- name: Set UFW default policy - allow outgoing + community.general.ufw: + direction: outgoing + default: allow + +- name: Allow SSH (port 22) through UFW + community.general.ufw: + rule: allow + port: "22" + proto: tcp + comment: "SSH" + +- name: Allow HTTPS (port 443) through UFW + community.general.ufw: + rule: allow + port: "443" + proto: tcp + comment: "HTTPS" + +- name: Enable UFW + community.general.ufw: + state: enabled + +# --- fail2ban Configuration --- + +- name: Deploy fail2ban SSH jail configuration + ansible.builtin.template: + src: fail2ban-sshd.conf.j2 + dest: /etc/fail2ban/jail.d/sshd.conf + owner: root + group: root + mode: "0644" + notify: Restart fail2ban + +- name: Ensure fail2ban is enabled and running + ansible.builtin.systemd: + name: fail2ban + enabled: true + state: started + +# --- Kernel Tuning --- + +- name: Deploy sysctl tuning configuration + ansible.builtin.template: + src: sysctl.conf.j2 + dest: /etc/sysctl.d/99-rustyip.conf + owner: root + group: root + mode: "0644" + notify: Reload sysctl + +- name: Deploy system-wide file descriptor limits + ansible.builtin.template: + src: limits.conf.j2 + dest: /etc/security/limits.d/99-rustyip.conf + owner: root + group: root + mode: "0644" + +# --- Automatic Security Updates --- + +- name: Enable unattended-upgrades for security updates + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + content: | + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; + APT::Periodic::AutocleanInterval "7"; + owner: root + group: root + mode: "0644" diff --git a/deploy/ansible/roles/base/templates/fail2ban-sshd.conf.j2 b/deploy/ansible/roles/base/templates/fail2ban-sshd.conf.j2 new file mode 100644 index 0000000..bfbedda --- /dev/null +++ b/deploy/ansible/roles/base/templates/fail2ban-sshd.conf.j2 @@ -0,0 +1,14 @@ +[sshd] +enabled = true +port = ssh +filter = sshd +maxretry = 5 +findtime = 600 +bantime = 3600 + +# Use iptables directly instead of UFW +banaction = iptables-multiport +banaction_allports = iptables-allports + +logpath = /var/log/auth.log +backend = auto diff --git a/deploy/ansible/roles/base/templates/limits.conf.j2 b/deploy/ansible/roles/base/templates/limits.conf.j2 new file mode 100644 index 0000000..4bce7c3 --- /dev/null +++ b/deploy/ansible/roles/base/templates/limits.conf.j2 @@ -0,0 +1,5 @@ +# System-wide file descriptor limits for high-throughput service +* soft nofile 1048576 +* hard nofile 1048576 +root soft nofile 1048576 +root hard nofile 1048576 diff --git a/deploy/ansible/roles/base/templates/sysctl.conf.j2 b/deploy/ansible/roles/base/templates/sysctl.conf.j2 new file mode 100644 index 0000000..671913e --- /dev/null +++ b/deploy/ansible/roles/base/templates/sysctl.conf.j2 @@ -0,0 +1,59 @@ +# Network performance tuning for high-throughput web service + +# Socket buffer sizes +net.core.rmem_max = 16777216 +net.core.wmem_max = 16777216 +net.core.rmem_default = 1048576 +net.core.wmem_default = 1048576 + +# TCP buffer auto-tuning +net.ipv4.tcp_rmem = 4096 87380 16777216 +net.ipv4.tcp_wmem = 4096 65536 16777216 + +# Connection backlog +net.core.somaxconn = 65535 +net.core.netdev_max_backlog = 65535 +net.ipv4.tcp_max_syn_backlog = 65535 + +# TCP connection reuse and timeout +net.ipv4.tcp_tw_reuse = 1 +net.ipv4.tcp_fin_timeout = 15 + +# TCP keepalive (detect dead connections faster) +net.ipv4.tcp_keepalive_time = 300 +net.ipv4.tcp_keepalive_intvl = 30 +net.ipv4.tcp_keepalive_probes = 5 + +# Ephemeral port range +net.ipv4.ip_local_port_range = 1024 65535 + +# TCP Fast Open (client + server) +net.ipv4.tcp_fastopen = 3 + +# Disable slow start after idle +net.ipv4.tcp_slow_start_after_idle = 0 + +# Enable TCP window scaling +net.ipv4.tcp_window_scaling = 1 + +# Enable TCP timestamps +net.ipv4.tcp_timestamps = 1 + +# Enable selective acknowledgements +net.ipv4.tcp_sack = 1 + +# SYN flood protection +net.ipv4.tcp_syncookies = 1 +net.ipv4.tcp_max_tw_buckets = 2000000 + +# File descriptor limits +fs.file-max = 2097152 +fs.nr_open = 2097152 + +# VM tuning +vm.swappiness = 10 +vm.dirty_ratio = 15 +vm.dirty_background_ratio = 5 + +# Connection tracking (for iptables/fail2ban) +net.netfilter.nf_conntrack_max = 262144 diff --git a/deploy/ansible/roles/docker/handlers/main.yml b/deploy/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000..233e114 --- /dev/null +++ b/deploy/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: Restart docker + ansible.builtin.systemd: + name: docker + state: restarted diff --git a/deploy/ansible/roles/docker/tasks/main.yml b/deploy/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000..08fccc9 --- /dev/null +++ b/deploy/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,69 @@ +--- +- name: Create Docker apt keyring directory + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + +- name: Add Docker GPG key + ansible.builtin.shell: | + curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + args: + creates: /etc/apt/keyrings/docker.gpg + +- name: Set permissions on Docker GPG key + ansible.builtin.file: + path: /etc/apt/keyrings/docker.gpg + mode: "0644" + +- name: Add Docker apt repository + ansible.builtin.shell: | + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list + args: + creates: /etc/apt/sources.list.d/docker.list + +- name: Install Docker CE packages + ansible.builtin.apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose-plugin + state: present + update_cache: true + +- name: Deploy Docker daemon configuration + ansible.builtin.template: + src: daemon.json.j2 + dest: /etc/docker/daemon.json + owner: root + group: root + mode: "0644" + notify: Restart docker + +- name: Ensure Docker is enabled and running + ansible.builtin.systemd: + name: docker + enabled: true + state: started + +- name: Configure Docker systemd override for unlimited file descriptors + ansible.builtin.file: + path: /etc/systemd/system/docker.service.d + state: directory + mode: "0755" + +- name: Deploy Docker systemd override + ansible.builtin.copy: + dest: /etc/systemd/system/docker.service.d/override.conf + content: | + [Service] + LimitNOFILE=1048576 + LimitNPROC=infinity + LimitCORE=infinity + owner: root + group: root + mode: "0644" + notify: + - Reload systemd + - Restart docker diff --git a/deploy/ansible/roles/docker/templates/daemon.json.j2 b/deploy/ansible/roles/docker/templates/daemon.json.j2 new file mode 100644 index 0000000..02925d3 --- /dev/null +++ b/deploy/ansible/roles/docker/templates/daemon.json.j2 @@ -0,0 +1,21 @@ +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "live-restore": true, + "default-ulimits": { + "nofile": { + "Name": "nofile", + "Hard": 1048576, + "Soft": 1048576 + }, + "nproc": { + "Name": "nproc", + "Hard": 65535, + "Soft": 65535 + } + }, + "default-shm-size": "256M" +} diff --git a/deploy/terraform/firewall.tf b/deploy/terraform/firewall.tf new file mode 100644 index 0000000..8c5d026 --- /dev/null +++ b/deploy/terraform/firewall.tf @@ -0,0 +1,26 @@ +resource "vultr_firewall_group" "rustyip" { + description = "${var.instance_label} firewall" +} + +# Allow SSH from anywhere (IPv4) +resource "vultr_firewall_rule" "ssh_v4" { + firewall_group_id = vultr_firewall_group.rustyip.id + protocol = "tcp" + ip_type = "v4" + subnet = "0.0.0.0" + subnet_size = 0 + port = "22" + notes = "Allow SSH from anywhere" +} + +# Allow HTTPS from Cloudflare only (IPv4) +resource "vultr_firewall_rule" "https_cloudflare_v4" { + firewall_group_id = vultr_firewall_group.rustyip.id + protocol = "tcp" + ip_type = "v4" + subnet = "0.0.0.0" + subnet_size = 0 + port = "443" + source = "cloudflare" + notes = "Allow HTTPS from Cloudflare IPv4" +} diff --git a/deploy/terraform/instance.tf b/deploy/terraform/instance.tf new file mode 100644 index 0000000..8c1fd8a --- /dev/null +++ b/deploy/terraform/instance.tf @@ -0,0 +1,15 @@ +resource "vultr_instance" "rustyip" { + plan = var.plan + region = var.region + os_id = var.os_id + label = var.instance_label + hostname = var.instance_label + enable_ipv6 = false + activation_email = false + backups = "disabled" + ddos_protection = false + firewall_group_id = vultr_firewall_group.rustyip.id + ssh_key_ids = [vultr_ssh_key.deploy.id] + + tags = [var.instance_label] +} diff --git a/deploy/terraform/main.tf b/deploy/terraform/main.tf new file mode 100644 index 0000000..3fbac16 --- /dev/null +++ b/deploy/terraform/main.tf @@ -0,0 +1,28 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + vultr = { + source = "vultr/vultr" + version = "~> 2.0" + } + } + + backend "s3" { + key = "rustyip/terraform.tfstate" + + # Cloudflare R2 (S3-compatible) + # bucket, endpoint, access_key, secret_key are passed via -backend-config + region = "auto" + skip_credentials_validation = true + skip_metadata_api_check = true + skip_region_validation = true + skip_requesting_account_id = true + skip_s3_checksum = true + use_path_style = true + } +} + +provider "vultr" { + api_key = var.vultr_api_key +} diff --git a/deploy/terraform/outputs.tf b/deploy/terraform/outputs.tf new file mode 100644 index 0000000..36f5e01 --- /dev/null +++ b/deploy/terraform/outputs.tf @@ -0,0 +1,10 @@ +output "instance_id" { + description = "Vultr instance ID" + value = vultr_instance.rustyip.id +} + +output "instance_ip" { + description = "VPS public IPv4 address" + value = vultr_instance.rustyip.main_ip + sensitive = true +} diff --git a/deploy/terraform/ssh_key.tf b/deploy/terraform/ssh_key.tf new file mode 100644 index 0000000..39e5f78 --- /dev/null +++ b/deploy/terraform/ssh_key.tf @@ -0,0 +1,4 @@ +resource "vultr_ssh_key" "deploy" { + name = "${var.instance_label}-deploy" + ssh_key = var.ssh_public_key +} diff --git a/deploy/terraform/variables.tf b/deploy/terraform/variables.tf new file mode 100644 index 0000000..4239081 --- /dev/null +++ b/deploy/terraform/variables.tf @@ -0,0 +1,34 @@ +variable "vultr_api_key" { + description = "Vultr API key" + type = string + sensitive = true +} + +variable "ssh_public_key" { + description = "SSH public key for VPS access" + type = string +} + +variable "region" { + description = "Vultr region ID" + type = string + default = "dfw" +} + +variable "plan" { + description = "Vultr plan ID (High Frequency 2 vCPU / 2 GB)" + type = string + default = "vhf-2c-2gb" +} + +variable "os_id" { + description = "Vultr OS ID (Debian 13 Trixie x64)" + type = number + default = 2625 +} + +variable "instance_label" { + description = "Label for the VPS instance" + type = string + default = "rustyip" +}