mirror of
https://github.com/NetworkCats/rustyip.git
synced 2026-11-13 16:17:46 +00:00
feat: add deployment infrastructure with Terraform, Ansible, and GitHub Actions
This commit is contained in:
@@ -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 <<EOF
|
||||
all:
|
||||
hosts:
|
||||
vps:
|
||||
ansible_host: "${{ steps.vps.outputs.ip }}"
|
||||
ansible_user: root
|
||||
ansible_ssh_private_key_file: ~/.ssh/deploy_key
|
||||
ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new"
|
||||
EOF
|
||||
|
||||
- name: Run Ansible playbook
|
||||
working-directory: deploy/ansible
|
||||
env:
|
||||
APP_IMAGE: ${{ needs.build.outputs.image_full }}
|
||||
SITE_DOMAIN: ${{ secrets.SITE_DOMAIN }}
|
||||
DB_UPDATE_URL: ${{ secrets.DB_UPDATE_URL }}
|
||||
ORIGIN_CERT: ${{ secrets.ORIGIN_CERT }}
|
||||
ORIGIN_KEY: ${{ secrets.ORIGIN_KEY }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
FIRST_DEPLOY: ${{ steps.detect.outputs.first_deploy }}
|
||||
run: |
|
||||
ansible-playbook -i inventory.yml playbook.yml
|
||||
|
||||
# ---- Verify deployment ----
|
||||
- name: Verify deployment health
|
||||
run: |
|
||||
DEPLOY_COLOR=$(ssh -i ~/.ssh/deploy_key root@"${{ steps.vps.outputs.ip }}" \
|
||||
"cat /opt/rustyip/.active_color")
|
||||
for i in $(seq 1 15); do
|
||||
status=$(ssh -i ~/.ssh/deploy_key root@"${{ steps.vps.outputs.ip }}" \
|
||||
"docker inspect --format='{{ '{{' }}.State.Health.Status{{ '}}' }}' rustyip-app-${DEPLOY_COLOR}" 2>/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
|
||||
+14
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 '' }}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart docker
|
||||
ansible.builtin.systemd:
|
||||
name: docker
|
||||
state: restarted
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
resource "vultr_ssh_key" "deploy" {
|
||||
name = "${var.instance_label}-deploy"
|
||||
ssh_key = var.ssh_public_key
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user