Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
9
infra/ansible/inventory/bootstrap.yml
Normal file
9
infra/ansible/inventory/bootstrap.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
all:
|
||||
children:
|
||||
pw:
|
||||
hosts:
|
||||
pw-server:
|
||||
ansible_host: 207.174.124.71
|
||||
ansible_user: root
|
||||
ansible_port: 22022
|
||||
ansible_python_interpreter: /usr/bin/python3
|
||||
109
infra/ansible/inventory/group_vars/all.yml
Normal file
109
infra/ansible/inventory/group_vars/all.yml
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
# Performance West — Global Ansible Variables
|
||||
|
||||
# ── Domains ───────────────────────────────────────────────────────────────────
|
||||
domain: performancewest.net
|
||||
api_domain: api.performancewest.net
|
||||
portal_domain: portal.performancewest.net
|
||||
crm_domain: crm.performancewest.net
|
||||
analytics_domain: analytics.performancewest.net
|
||||
listmonk_domain: lists.performancewest.net
|
||||
dev_domain: dev.performancewest.net
|
||||
dev_api_domain: api.dev.performancewest.net
|
||||
shkeeper_domain: pay.performancewest.net
|
||||
shkeeper_admin_domain: crypto.performancewest.net
|
||||
minio_domain: minio.performancewest.net
|
||||
minio_console_domain: minio-console.performancewest.net
|
||||
# Windows DocServer VM (connects to MinIO externally for DOCX→PDF conversion)
|
||||
docserver_ip: 108.181.102.34
|
||||
|
||||
# mautic retired — replaced by Listmonk; kept so old templates don't break
|
||||
mautic_domain: mail.performancewest.net
|
||||
|
||||
deploy_user: deploy
|
||||
ssh_port: 22022
|
||||
project_dir: /opt/performancewest
|
||||
dev_project_dir: /opt/performancewest-dev
|
||||
|
||||
# ── Ports (internal — host-bound by Docker or k3s) ───────────────────────────
|
||||
site_port: 4322
|
||||
api_port: 3001
|
||||
erpnext_port: 8080
|
||||
listmonk_port: 9100 # Docker maps 9100→9000 inside container
|
||||
umami_port: 3100
|
||||
minio_port: 9000
|
||||
minio_console_port: 9001
|
||||
shkeeper_port: 5000 # SHKeeper web UI (k3s NodePort)
|
||||
dev_site_port: 4323
|
||||
dev_api_port: 3002
|
||||
|
||||
# ── Let's Encrypt ─────────────────────────────────────────────────────────────
|
||||
certbot_email: info@performancewest.net
|
||||
certbot_webroot: /var/www/certbot
|
||||
|
||||
# ── PostgreSQL (API DB) ───────────────────────────────────────────────────────
|
||||
pg_database: performancewest
|
||||
pg_user: pw
|
||||
pg_password: "{{ vault_db_password }}"
|
||||
pg_backup_dir: /opt/backups/postgresql
|
||||
pg_backup_retention_days: 30
|
||||
|
||||
# Backwards-compat aliases used by some roles
|
||||
db_name: "{{ pg_database }}"
|
||||
db_user: "{{ pg_user }}"
|
||||
db_password: "{{ pg_password }}"
|
||||
|
||||
# ── ERPNext ───────────────────────────────────────────────────────────────────
|
||||
erpnext_db_password: "{{ vault_erpnext_db_password }}"
|
||||
erpnext_admin_password: "{{ vault_erpnext_admin_password }}"
|
||||
|
||||
# ── Umami ─────────────────────────────────────────────────────────────────────
|
||||
umami_db_password: "{{ vault_umami_db_password }}"
|
||||
umami_app_secret: "{{ vault_umami_app_secret }}"
|
||||
|
||||
# ── MinIO ─────────────────────────────────────────────────────────────────────
|
||||
minio_access_key: "{{ vault_minio_access_key }}"
|
||||
minio_secret_key: "{{ vault_minio_secret_key }}"
|
||||
minio_bucket: performancewest
|
||||
|
||||
# ── Ollama ────────────────────────────────────────────────────────────────────
|
||||
ollama_model: "{{ vault_ollama_model | default('qwen2.5:7b') }}"
|
||||
|
||||
# ── Transactional email (Carbonio — co.carrierone.com) ────────────────────────
|
||||
# Used by: API (nodemailer), Python workers (smtplib), ERPNext notifications
|
||||
# NOT used by Listmonk — Listmonk has its own SMTP configured in its admin UI
|
||||
smtp_host: co.carrierone.com
|
||||
smtp_port: 587
|
||||
smtp_user: noreply@performancewest.net
|
||||
smtp_pass: "{{ vault_smtp_pass }}"
|
||||
smtp_from: "Performance West <noreply@performancewest.net>"
|
||||
smtp_admin_email: ops@performancewest.net
|
||||
|
||||
# ── Listmonk (mass-mail via SMTP2GO) ─────────────────────────────────────────
|
||||
# Listmonk SMTP is configured via its web admin UI, not env vars.
|
||||
# These vars are kept here for documentation and manual reference.
|
||||
listmonk_smtp_host: mail.smtp2go.com
|
||||
listmonk_smtp_port: 587
|
||||
listmonk_smtp_user: "{{ vault_listmonk_smtp_user | default(smtp_user) }}"
|
||||
listmonk_smtp_pass: "{{ vault_listmonk_smtp_pass }}"
|
||||
listmonk_admin_user: "{{ vault_listmonk_admin_user }}"
|
||||
listmonk_admin_password: "{{ vault_listmonk_admin_password }}"
|
||||
|
||||
# ── Common packages ───────────────────────────────────────────────────────────
|
||||
common_packages:
|
||||
- curl
|
||||
- wget
|
||||
- git
|
||||
- htop
|
||||
- unzip
|
||||
- jq
|
||||
- rsync
|
||||
- ufw
|
||||
- fail2ban
|
||||
- chrony
|
||||
- unattended-upgrades
|
||||
- apt-transport-https
|
||||
- ca-certificates
|
||||
- gnupg
|
||||
- python3
|
||||
- python3-pip
|
||||
9
infra/ansible/inventory/hosts.yml
Normal file
9
infra/ansible/inventory/hosts.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
all:
|
||||
children:
|
||||
pw:
|
||||
hosts:
|
||||
pw-server:
|
||||
ansible_host: 207.174.124.71
|
||||
ansible_user: deploy
|
||||
ansible_port: 22022
|
||||
ansible_python_interpreter: /usr/bin/python3
|
||||
15
infra/ansible/playbooks/bootstrap.yml
Normal file
15
infra/ansible/playbooks/bootstrap.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
# Performance West — Bootstrap Security
|
||||
# Run ONCE on a fresh Debian server as root to harden SSH, create deploy user, set up firewall.
|
||||
#
|
||||
# Usage:
|
||||
# ansible-playbook playbooks/bootstrap.yml -i inventory/bootstrap.yml
|
||||
#
|
||||
# After this completes, all future playbooks use inventory/hosts.yml (as deploy user).
|
||||
|
||||
- name: Bootstrap server security
|
||||
hosts: pw
|
||||
become: true
|
||||
roles:
|
||||
- common
|
||||
- docker
|
||||
8
infra/ansible/playbooks/deploy-crons.yml
Normal file
8
infra/ansible/playbooks/deploy-crons.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
# Deploy only the worker-crons role (systemd timers).
|
||||
# Usage: ansible-playbook playbooks/deploy-crons.yml -i inventory/hosts.yml
|
||||
- name: Deploy worker cron timers
|
||||
hosts: pw
|
||||
become: true
|
||||
roles:
|
||||
- role: "{{ playbook_dir }}/../roles/worker-crons"
|
||||
39
infra/ansible/playbooks/deploy.yml
Normal file
39
infra/ansible/playbooks/deploy.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
# Performance West — Code Deploy (no infrastructure changes)
|
||||
# Usage: ansible-playbook playbooks/deploy.yml -i inventory/hosts.yml
|
||||
|
||||
- name: Deploy Performance West
|
||||
hosts: pw
|
||||
become: true
|
||||
tasks:
|
||||
- name: Sync project files
|
||||
ansible.builtin.synchronize:
|
||||
src: "{{ playbook_dir }}/../../"
|
||||
dest: "{{ project_dir }}/"
|
||||
delete: true
|
||||
rsync_opts:
|
||||
- "--exclude=node_modules"
|
||||
- "--exclude=dist"
|
||||
- "--exclude=.git"
|
||||
- "--exclude=site-old"
|
||||
- "--exclude=.env"
|
||||
|
||||
- name: Rebuild and restart containers
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ project_dir }}"
|
||||
build: always
|
||||
state: present
|
||||
register: compose_result
|
||||
|
||||
- name: Show compose output
|
||||
ansible.builtin.debug:
|
||||
var: compose_result.actions
|
||||
|
||||
- name: Wait for API health
|
||||
ansible.builtin.uri:
|
||||
url: "http://localhost:{{ api_port }}/api/v1/status"
|
||||
return_content: true
|
||||
register: health
|
||||
retries: 10
|
||||
delay: 3
|
||||
until: health.status == 200
|
||||
22
infra/ansible/playbooks/run-migrations.yml
Normal file
22
infra/ansible/playbooks/run-migrations.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
# Run a specific SQL migration against the app database
|
||||
# Usage: ansible-playbook playbooks/run-migrations.yml -e "migration=001_core_tables.sql"
|
||||
|
||||
- name: Run database migration
|
||||
hosts: pw
|
||||
become: true
|
||||
tasks:
|
||||
- name: Copy migration file
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../../api/migrations/{{ migration }}"
|
||||
dest: "/tmp/{{ migration }}"
|
||||
|
||||
- name: Execute migration
|
||||
community.docker.docker_container_exec:
|
||||
container: performancewest-postgres-1
|
||||
command: psql -U {{ db_user }} -d {{ db_name }} -f /tmp/{{ migration }}
|
||||
register: migration_result
|
||||
|
||||
- name: Show result
|
||||
ansible.builtin.debug:
|
||||
var: migration_result.stdout_lines
|
||||
34
infra/ansible/playbooks/site.yml
Normal file
34
infra/ansible/playbooks/site.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
# Performance West — Full Stack Provisioning
|
||||
# Provisions a fresh Debian 13 server from scratch.
|
||||
#
|
||||
# Usage:
|
||||
# ansible-playbook playbooks/site.yml -i inventory/hosts.yml --ask-vault-pass
|
||||
#
|
||||
# Roles run in order:
|
||||
# common — packages, deploy user, SSH hardening, UFW
|
||||
# docker — Docker CE + compose plugin + performancewest.service systemd unit
|
||||
# postgresql — API PostgreSQL + backup cron + migrations
|
||||
# app — Express API container
|
||||
# site — Astro static site container
|
||||
# erpnext — ERPNext CRM + workers + scheduler (custom image with frappe_crypto + frappe_adyen)
|
||||
# minio — MinIO object storage + bucket creation
|
||||
# workers — Python job server + Ollama LLM
|
||||
# shkeeper — k3s + Helm + SHKeeper (crypto payments: BTC/ETH/USDC/Polygon/TRX/BNB/LTC)
|
||||
# nginx — nginx + certbot TLS for all domains + fail2ban
|
||||
|
||||
- name: Provision Performance West server
|
||||
hosts: pw
|
||||
become: true
|
||||
roles:
|
||||
- common
|
||||
- docker
|
||||
- postgresql
|
||||
- app
|
||||
- site
|
||||
- erpnext
|
||||
- minio
|
||||
- workers
|
||||
- worker-crons
|
||||
- shkeeper
|
||||
- nginx
|
||||
2
infra/ansible/roles/app/defaults/main.yml
Normal file
2
infra/ansible/roles/app/defaults/main.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
---
|
||||
api_port: 3001
|
||||
52
infra/ansible/roles/app/tasks/main.yml
Normal file
52
infra/ansible/roles/app/tasks/main.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
- name: Create API deployment directory
|
||||
ansible.builtin.file:
|
||||
path: /opt/performancewest/api
|
||||
state: directory
|
||||
owner: "{{ deploy_user }}"
|
||||
group: "{{ deploy_user }}"
|
||||
mode: "0755"
|
||||
|
||||
- name: Sync API source code
|
||||
ansible.posix.synchronize:
|
||||
src: "{{ playbook_dir }}/../../api/"
|
||||
dest: /opt/performancewest/api/
|
||||
delete: true
|
||||
rsync_opts:
|
||||
- "--exclude=node_modules"
|
||||
- "--exclude=.env"
|
||||
- "--exclude=dist"
|
||||
notify: Rebuild api container
|
||||
|
||||
- name: Deploy API .env file
|
||||
ansible.builtin.template:
|
||||
src: app.env.j2
|
||||
dest: /opt/performancewest/api/.env
|
||||
owner: "{{ deploy_user }}"
|
||||
group: "{{ deploy_user }}"
|
||||
mode: "0600"
|
||||
notify: Rebuild api container
|
||||
|
||||
- name: Build API container
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose build api
|
||||
chdir: /opt/performancewest
|
||||
register: api_build
|
||||
changed_when: "'writing image' in api_build.stderr"
|
||||
|
||||
- name: Start API container
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d api
|
||||
chdir: /opt/performancewest
|
||||
register: api_start
|
||||
changed_when: "'Started' in api_start.stderr or 'Creating' in api_start.stderr"
|
||||
|
||||
- name: Wait for API health check
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ api_port }}/health"
|
||||
status_code: 200
|
||||
timeout: 5
|
||||
register: api_health
|
||||
retries: 12
|
||||
delay: 5
|
||||
until: api_health.status == 200
|
||||
114
infra/ansible/roles/app/templates/app.env.j2
Normal file
114
infra/ansible/roles/app/templates/app.env.j2
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# {{ ansible_managed }}
|
||||
# Performance West — API + Workers environment variables
|
||||
# Deployed to {{ project_dir }}/.env by Ansible (ansible-playbook site.yml)
|
||||
# DO NOT edit this file directly on the server — edit the j2 template and re-run.
|
||||
|
||||
NODE_ENV=production
|
||||
PORT={{ api_port }}
|
||||
|
||||
# ── Database (PostgreSQL) ─────────────────────────────────────────────────────
|
||||
DATABASE_URL=postgresql://{{ pg_user }}:{{ pg_password }}@api-postgres:5432/{{ pg_database }}
|
||||
DB_PASSWORD={{ pg_password }}
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
ADMIN_JWT_SECRET={{ vault_admin_jwt_secret }}
|
||||
PW_INTERNAL_API_KEY={{ vault_pw_internal_api_key }}
|
||||
WEBHOOK_SECRET={{ vault_webhook_secret }}
|
||||
|
||||
# ── ERPNext (CRM — source of truth) ──────────────────────────────────────────
|
||||
ERPNEXT_URL=http://erpnext:8080
|
||||
ERPNEXT_SITE_NAME={{ domain }}
|
||||
ERPNEXT_API_KEY={{ vault_erpnext_api_key }}
|
||||
ERPNEXT_API_SECRET={{ vault_erpnext_api_secret }}
|
||||
ERPNEXT_DB_PASSWORD={{ erpnext_db_password }}
|
||||
|
||||
# ── MinIO (document storage) ─────────────────────────────────────────────────
|
||||
MINIO_ENDPOINT=minio
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY={{ minio_access_key }}
|
||||
MINIO_SECRET_KEY={{ minio_secret_key }}
|
||||
MINIO_BUCKET={{ minio_bucket }}
|
||||
|
||||
# ── Stripe ───────────────────────────────────────────────────────────────────
|
||||
STRIPE_SECRET_KEY={{ vault_stripe_secret_key }}
|
||||
STRIPE_PUBLISHABLE_KEY={{ vault_stripe_publishable_key }}
|
||||
STRIPE_WEBHOOK_SECRET={{ vault_stripe_webhook_secret }}
|
||||
STRIPE_IDENTITY_WEBHOOK_SECRET={{ vault_stripe_identity_webhook_secret }}
|
||||
# Test keys — used only when NODE_ENV != production
|
||||
STRIPE_TEST_SECRET_KEY={{ vault_stripe_test_secret_key | default('') }}
|
||||
STRIPE_TEST_WEBHOOK_SECRET={{ vault_stripe_test_webhook_secret | default('') }}
|
||||
STRIPE_TEST_IDENTITY_WEBHOOK_SECRET={{ vault_stripe_test_identity_webhook_secret | default('') }}
|
||||
|
||||
# ── PayPal ───────────────────────────────────────────────────────────────────
|
||||
PAYPAL_CLIENT_ID={{ vault_paypal_client_id | default('') }}
|
||||
PAYPAL_CLIENT_SECRET={{ vault_paypal_client_secret | default('') }}
|
||||
PAYPAL_API_URL=https://api-m.paypal.com
|
||||
|
||||
# ── SHKeeper (crypto payments) ────────────────────────────────────────────────
|
||||
SHKEEPER_URL=http://127.0.0.1:5000
|
||||
SHKEEPER_PUBLIC_URL=https://{{ shkeeper_admin_domain }}
|
||||
SHKEEPER_API_KEY={{ vault_shkeeper_api_key | default('') }}
|
||||
|
||||
# ── Workers ───────────────────────────────────────────────────────────────────
|
||||
WORKER_URL=http://workers:8090
|
||||
|
||||
# ── Transactional email — Carbonio (co.carrierone.com) ───────────────────────
|
||||
# All transactional mail: order confirmations, worker notifications, ERPNext alerts.
|
||||
# Listmonk mass-mail uses SMTP2GO — configured separately in the Listmonk admin UI.
|
||||
SMTP_HOST={{ smtp_host }}
|
||||
SMTP_PORT={{ smtp_port }}
|
||||
SMTP_USER={{ smtp_user }}
|
||||
SMTP_PASS={{ smtp_pass }}
|
||||
SMTP_FROM={{ smtp_from }}
|
||||
ADMIN_EMAIL={{ smtp_admin_email }}
|
||||
|
||||
# ── Listmonk (email marketing) ────────────────────────────────────────────────
|
||||
LISTMONK_URL=http://listmonk:9000
|
||||
LISTMONK_ADMIN_USER={{ listmonk_admin_user }}
|
||||
LISTMONK_ADMIN_PASSWORD={{ listmonk_admin_password }}
|
||||
|
||||
# ── Umami analytics ──────────────────────────────────────────────────────────
|
||||
UMAMI_DB_PASSWORD={{ umami_db_password }}
|
||||
UMAMI_APP_SECRET={{ umami_app_secret }}
|
||||
|
||||
# ── Anytime Mailbox (IMAP for OTP auto-fetch) ─────────────────────────────────
|
||||
ANYTIME_MAILBOX_IMAP_HOST={{ smtp_host }}
|
||||
ANYTIME_MAILBOX_IMAP_PORT=993
|
||||
ANYTIME_MAILBOX_IMAP_SSL=true
|
||||
ANYTIME_MAILBOX_IMAP_USER={{ vault_anytime_mailbox_imap_user | default(smtp_user) }}
|
||||
ANYTIME_MAILBOX_IMAP_PASS={{ vault_anytime_mailbox_imap_pass | default(smtp_pass) }}
|
||||
ANYTIME_MAILBOX_IMAP_FOLDER=INBOX
|
||||
ANYTIME_MAILBOX_OTP_SENDER_HINT=anytimemailbox
|
||||
ANYTIME_MAILBOX_OTP_TIMEOUT_SECONDS=180
|
||||
ANYTIME_MAILBOX_OTP_POLL_SECONDS=6
|
||||
ANYTIME_MAILBOX_SIGNUP_EMAIL={{ vault_anytime_mailbox_signup_email | default('filings@performancewest.net') }}
|
||||
ANYTIME_MAILBOX_SIGNUP_PHONE={{ vault_anytime_mailbox_signup_phone | default('+16025550123') }}
|
||||
ANYTIME_MAILBOX_DEFAULT_PASSWORD={{ vault_anytime_mailbox_default_password | default('') }}
|
||||
|
||||
# ── Relay (ACH / card routing) ────────────────────────────────────────────────
|
||||
RELAY_IMAP_HOST={{ vault_relay_imap_host | default('') }}
|
||||
RELAY_IMAP_PORT={{ vault_relay_imap_port | default('993') }}
|
||||
RELAY_IMAP_USER={{ vault_relay_imap_user | default('') }}
|
||||
RELAY_IMAP_PASS={{ vault_relay_imap_pass | default('') }}
|
||||
RELAY_IMAP_FOLDER={{ vault_relay_imap_folder | default('INBOX') }}
|
||||
RELAY_FILING_CARD_ID={{ vault_relay_filing_card_id | default('') }}
|
||||
CRYPTO_FILING_CARD_ID={{ vault_crypto_filing_card_id | default('') }}
|
||||
|
||||
# ── Porkbun (.ca domain registration) ────────────────────────────────────────
|
||||
PORKBUN_API_KEY={{ vault_porkbun_api_key | default('') }}
|
||||
PORKBUN_SECRET_KEY={{ vault_porkbun_secret_key | default('') }}
|
||||
|
||||
# ── Flowroute (Canadian DID provisioning) ────────────────────────────────────
|
||||
FLOWROUTE_ACCESS_KEY={{ vault_flowroute_access_key | default('') }}
|
||||
FLOWROUTE_SECRET_KEY={{ vault_flowroute_secret_key | default('') }}
|
||||
|
||||
# ── HestiaCP (hosting provisioner) ───────────────────────────────────────────
|
||||
HESTIA_URL={{ vault_hestia_url | default('https://cp.carrierone.com:8083') }}
|
||||
HESTIA_USER={{ vault_hestia_user | default('admin') }}
|
||||
HESTIA_PASS={{ vault_hestia_pass | default('') }}
|
||||
|
||||
# ── Application URLs ──────────────────────────────────────────────────────────
|
||||
DOMAIN=https://{{ domain }}
|
||||
SITE_URL=https://{{ domain }}
|
||||
API_URL=https://{{ api_domain }}
|
||||
PORTAL_URL=https://{{ portal_domain }}
|
||||
20
infra/ansible/roles/common/defaults/main.yml
Normal file
20
infra/ansible/roles/common/defaults/main.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
common_packages:
|
||||
- curl
|
||||
- wget
|
||||
- git
|
||||
- htop
|
||||
- unzip
|
||||
- jq
|
||||
- rsync
|
||||
- ufw
|
||||
- fail2ban
|
||||
- chrony
|
||||
- unattended-upgrades
|
||||
- apt-transport-https
|
||||
- ca-certificates
|
||||
- gnupg
|
||||
- python3
|
||||
- python3-pip
|
||||
deploy_user: deploy
|
||||
ssh_port: 22022
|
||||
5
infra/ansible/roles/common/handlers/main.yml
Normal file
5
infra/ansible/roles/common/handlers/main.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
- name: Restart sshd
|
||||
ansible.builtin.systemd:
|
||||
name: sshd
|
||||
state: restarted
|
||||
122
infra/ansible/roles/common/tasks/main.yml
Normal file
122
infra/ansible/roles/common/tasks/main.yml
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
- name: Update apt cache
|
||||
ansible.builtin.apt:
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
- name: Upgrade all packages
|
||||
ansible.builtin.apt:
|
||||
upgrade: safe
|
||||
|
||||
- name: Install common packages
|
||||
ansible.builtin.apt:
|
||||
name: "{{ common_packages }}"
|
||||
state: present
|
||||
|
||||
- name: Create deploy user
|
||||
ansible.builtin.user:
|
||||
name: "{{ deploy_user }}"
|
||||
shell: /bin/bash
|
||||
groups: sudo
|
||||
append: true
|
||||
create_home: true
|
||||
state: present
|
||||
|
||||
- name: Set authorized key for deploy user
|
||||
ansible.posix.authorized_key:
|
||||
user: "{{ deploy_user }}"
|
||||
key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
|
||||
state: present
|
||||
|
||||
- name: Allow deploy user passwordless sudo
|
||||
ansible.builtin.copy:
|
||||
content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
|
||||
dest: "/etc/sudoers.d/{{ deploy_user }}"
|
||||
mode: "0440"
|
||||
validate: "visudo -cf %s"
|
||||
|
||||
- name: Deploy hardened sshd_config
|
||||
ansible.builtin.template:
|
||||
src: sshd_config.j2
|
||||
dest: /etc/ssh/sshd_config
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
validate: "sshd -t -f %s"
|
||||
notify: Restart sshd
|
||||
|
||||
- name: Configure UFW defaults - deny incoming
|
||||
community.general.ufw:
|
||||
direction: incoming
|
||||
policy: deny
|
||||
|
||||
- name: Configure UFW defaults - allow outgoing
|
||||
community.general.ufw:
|
||||
direction: outgoing
|
||||
policy: allow
|
||||
|
||||
- name: Allow SSH on custom port
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: "{{ ssh_port }}"
|
||||
proto: tcp
|
||||
comment: "SSH custom port"
|
||||
|
||||
- name: Enable UFW
|
||||
community.general.ufw:
|
||||
state: enabled
|
||||
|
||||
- name: Enable and start chrony
|
||||
ansible.builtin.systemd:
|
||||
name: chrony
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Configure unattended-upgrades — origins
|
||||
ansible.builtin.copy:
|
||||
content: |
|
||||
// Automatically install security updates and stable updates
|
||||
Unattended-Upgrade::Origins-Pattern {
|
||||
"origin=Debian,codename=${distro_codename},label=Debian";
|
||||
"origin=Debian,codename=${distro_codename},label=Debian-Security";
|
||||
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
|
||||
"origin=Debian,codename=${distro_codename}-updates,label=Debian";
|
||||
};
|
||||
|
||||
// Auto-remove unused kernel packages and dependencies
|
||||
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
|
||||
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
|
||||
Unattended-Upgrade::Remove-Unused-Dependencies "true";
|
||||
|
||||
// Reboot at 4 AM if a kernel update requires it
|
||||
Unattended-Upgrade::Automatic-Reboot "true";
|
||||
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
|
||||
|
||||
// Email notification (optional — only if mail is configured)
|
||||
// Unattended-Upgrade::Mail "root";
|
||||
|
||||
// Log to syslog
|
||||
Unattended-Upgrade::SyslogEnable "true";
|
||||
dest: /etc/apt/apt.conf.d/50unattended-upgrades
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Configure auto-update schedule
|
||||
ansible.builtin.copy:
|
||||
content: |
|
||||
// Run apt update and unattended-upgrade daily
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
APT::Periodic::Download-Upgradeable-Packages "1";
|
||||
APT::Periodic::AutocleanInterval "7";
|
||||
dest: /etc/apt/apt.conf.d/20auto-upgrades
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Enable and start unattended-upgrades
|
||||
ansible.builtin.systemd:
|
||||
name: unattended-upgrades
|
||||
enabled: true
|
||||
state: started
|
||||
46
infra/ansible/roles/common/templates/sshd_config.j2
Normal file
46
infra/ansible/roles/common/templates/sshd_config.j2
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# {{ ansible_managed }}
|
||||
# Hardened SSH configuration for Performance West
|
||||
|
||||
Port {{ ssh_port }}
|
||||
AddressFamily inet
|
||||
ListenAddress 0.0.0.0
|
||||
|
||||
# Authentication
|
||||
PermitRootLogin no
|
||||
PubkeyAuthentication yes
|
||||
PasswordAuthentication no
|
||||
ChallengeResponseAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
UsePAM yes
|
||||
|
||||
# Only allow the deploy user
|
||||
AllowUsers {{ deploy_user }}
|
||||
|
||||
# Disable unused auth methods
|
||||
GSSAPIAuthentication no
|
||||
KerberosAuthentication no
|
||||
HostbasedAuthentication no
|
||||
PermitEmptyPasswords no
|
||||
|
||||
# Session settings
|
||||
MaxAuthTries 3
|
||||
MaxSessions 5
|
||||
LoginGraceTime 30
|
||||
ClientAliveInterval 300
|
||||
ClientAliveCountMax 2
|
||||
|
||||
# Disable forwarding (not needed for this server)
|
||||
AllowTcpForwarding no
|
||||
X11Forwarding no
|
||||
AllowAgentForwarding no
|
||||
|
||||
# Logging
|
||||
SyslogFacility AUTH
|
||||
LogLevel VERBOSE
|
||||
|
||||
# Misc
|
||||
PrintMotd no
|
||||
AcceptEnv LANG LC_*
|
||||
|
||||
# Use internal sftp
|
||||
Subsystem sftp internal-sftp
|
||||
2
infra/ansible/roles/docker/defaults/main.yml
Normal file
2
infra/ansible/roles/docker/defaults/main.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
---
|
||||
docker_compose_version: "v2.30.0"
|
||||
15
infra/ansible/roles/docker/handlers/main.yml
Normal file
15
infra/ansible/roles/docker/handlers/main.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
- name: Restart docker
|
||||
ansible.builtin.systemd:
|
||||
name: docker
|
||||
state: restarted
|
||||
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Enable performancewest service
|
||||
ansible.builtin.systemd:
|
||||
name: performancewest
|
||||
enabled: true
|
||||
state: started
|
||||
85
infra/ansible/roles/docker/tasks/main.yml
Normal file
85
infra/ansible/roles/docker/tasks/main.yml
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
- name: Remove old Docker packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- docker
|
||||
- docker-engine
|
||||
- docker.io
|
||||
- containerd
|
||||
- runc
|
||||
state: absent
|
||||
|
||||
- name: Install prerequisites for Docker repo
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- apt-transport-https
|
||||
- ca-certificates
|
||||
- curl
|
||||
- gnupg
|
||||
state: present
|
||||
|
||||
- name: Add Docker 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 CE and plugins
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- docker-ce
|
||||
- docker-ce-cli
|
||||
- containerd.io
|
||||
- docker-buildx-plugin
|
||||
- docker-compose-plugin
|
||||
state: present
|
||||
update_cache: true
|
||||
notify: Restart docker
|
||||
|
||||
- name: Ensure Docker service is enabled and started
|
||||
ansible.builtin.systemd:
|
||||
name: docker
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Add deploy user to docker group
|
||||
ansible.builtin.user:
|
||||
name: "{{ deploy_user }}"
|
||||
groups: docker
|
||||
append: true
|
||||
|
||||
- name: Verify Docker Compose plugin is available
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose version
|
||||
changed_when: false
|
||||
register: docker_compose_check
|
||||
failed_when: docker_compose_check.rc != 0
|
||||
|
||||
# --- Auto-start on reboot via systemd ---
|
||||
|
||||
- name: Deploy performancewest systemd service unit
|
||||
ansible.builtin.template:
|
||||
src: performancewest.service.j2
|
||||
dest: /etc/systemd/system/performancewest.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Reload systemd
|
||||
- Enable performancewest service
|
||||
|
||||
- name: Ensure performancewest service is enabled and started
|
||||
ansible.builtin.systemd:
|
||||
name: performancewest
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
[Unit]
|
||||
Description=Performance West Docker Compose Stack
|
||||
Documentation=https://performancewest.net
|
||||
# Wait for Docker daemon AND full network (including DNS) before starting.
|
||||
# network-online.target ensures the host has an IP + default route before
|
||||
# Docker tries to resolve container hostnames or connect to external services.
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory={{ project_dir }}
|
||||
|
||||
# Start: bring all services up detached; remove orphans from previous deploys
|
||||
ExecStart=/usr/bin/docker compose up -d --remove-orphans
|
||||
|
||||
# Stop: graceful shutdown — 120 s drain gives ERPNext time to finish requests
|
||||
ExecStop=/usr/bin/docker compose down --timeout 120
|
||||
|
||||
# Reload (systemctl reload performancewest): re-apply compose without full restart
|
||||
ExecReload=/usr/bin/docker compose up -d --remove-orphans
|
||||
|
||||
# Give containers plenty of time to start (ERPNext + Frappe bench takes ~2 min)
|
||||
TimeoutStartSec=300
|
||||
TimeoutStopSec=180
|
||||
|
||||
# Restart on failure (e.g. Docker daemon crash), but not on clean stop
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
|
||||
User={{ deploy_user }}
|
||||
Group={{ deploy_user }}
|
||||
|
||||
# Load .env so docker compose can expand all ${VAR} references
|
||||
EnvironmentFile=-{{ project_dir }}/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
2
infra/ansible/roles/erpnext/defaults/main.yml
Normal file
2
infra/ansible/roles/erpnext/defaults/main.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
---
|
||||
erpnext_port: 8080
|
||||
10
infra/ansible/roles/erpnext/handlers/main.yml
Normal file
10
infra/ansible/roles/erpnext/handlers/main.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
- name: restart erpnext
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ project_dir }}"
|
||||
services:
|
||||
- erpnext
|
||||
- erpnext-worker-default
|
||||
- erpnext-worker-short
|
||||
- erpnext-scheduler
|
||||
state: restarted
|
||||
68
infra/ansible/roles/erpnext/tasks/main.yml
Normal file
68
infra/ansible/roles/erpnext/tasks/main.yml
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
# Performance West — ERPNext role
|
||||
# Uses frappe/erpnext:version-15 with MariaDB 10.6 (officially supported stack).
|
||||
|
||||
- name: Ensure ERPNext containers are running
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ project_dir }}"
|
||||
services:
|
||||
- erpnext
|
||||
- erpnext-worker-default
|
||||
- erpnext-worker-short
|
||||
- erpnext-scheduler
|
||||
- erpnext-mariadb
|
||||
- erpnext-redis
|
||||
state: present
|
||||
register: erpnext_compose
|
||||
|
||||
- name: Wait for MariaDB to accept connections
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
docker compose exec -T erpnext-mariadb
|
||||
mysqladmin ping -u root -p{{ erpnext_db_password }} --silent
|
||||
chdir: "{{ project_dir }}"
|
||||
register: mariadb_ready
|
||||
retries: 20
|
||||
delay: 5
|
||||
until: mariadb_ready.rc == 0
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for ERPNext gunicorn to be ready
|
||||
ansible.builtin.uri:
|
||||
url: "http://localhost:{{ erpnext_port }}"
|
||||
return_content: false
|
||||
status_code: [200, 302, 307, 308]
|
||||
register: erpnext_health
|
||||
retries: 30
|
||||
delay: 10
|
||||
until: erpnext_health.status in [200, 302, 307, 308]
|
||||
|
||||
- name: Initialize ERPNext site (first run only)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
docker compose exec -T erpnext
|
||||
bench new-site {{ domain }}
|
||||
--db-host erpnext-mariadb
|
||||
--db-port 3306
|
||||
--db-name erpnext
|
||||
--db-root-username root
|
||||
--db-root-password {{ erpnext_db_password }}
|
||||
--db-password {{ erpnext_db_password }}
|
||||
--admin-password {{ vault_erpnext_admin_password }}
|
||||
--install-app erpnext
|
||||
chdir: "{{ project_dir }}"
|
||||
creates: "{{ project_dir }}/erpnext-initialized"
|
||||
register: erpnext_init
|
||||
|
||||
- name: Set ERPNext site as default
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose exec -T erpnext bench use {{ domain }}
|
||||
chdir: "{{ project_dir }}"
|
||||
when: erpnext_init.changed
|
||||
changed_when: true
|
||||
|
||||
- name: Mark ERPNext as initialized
|
||||
ansible.builtin.file:
|
||||
path: "{{ project_dir }}/erpnext-initialized"
|
||||
state: touch
|
||||
when: erpnext_init.changed
|
||||
4
infra/ansible/roles/minio/defaults/main.yml
Normal file
4
infra/ansible/roles/minio/defaults/main.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
minio_access_key: "{{ vault_minio_access_key }}"
|
||||
minio_secret_key: "{{ vault_minio_secret_key }}"
|
||||
minio_bucket: performancewest
|
||||
48
infra/ansible/roles/minio/tasks/main.yml
Normal file
48
infra/ansible/roles/minio/tasks/main.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
# Performance West — MinIO role
|
||||
# Starts MinIO and creates the required bucket.
|
||||
|
||||
- name: Ensure MinIO container is running
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ project_dir }}"
|
||||
services:
|
||||
- minio
|
||||
state: present
|
||||
register: minio_compose
|
||||
|
||||
- name: Wait for MinIO to be ready
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ minio_port }}/minio/health/live"
|
||||
status_code: 200
|
||||
timeout: 5
|
||||
register: minio_health
|
||||
retries: 12
|
||||
delay: 5
|
||||
until: minio_health.status == 200
|
||||
|
||||
- name: Install MinIO client (mc)
|
||||
ansible.builtin.get_url:
|
||||
url: https://dl.min.io/client/mc/release/linux-amd64/mc
|
||||
dest: /usr/local/bin/mc
|
||||
mode: "0755"
|
||||
force: false
|
||||
|
||||
- name: Configure mc alias for local MinIO
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
mc alias set local
|
||||
http://127.0.0.1:{{ minio_port }}
|
||||
{{ minio_access_key }}
|
||||
{{ minio_secret_key }}
|
||||
changed_when: false
|
||||
|
||||
- name: Create performancewest bucket if not exists
|
||||
ansible.builtin.command:
|
||||
cmd: mc mb --ignore-existing local/{{ minio_bucket }}
|
||||
register: bucket_create
|
||||
changed_when: "'Bucket created' in bucket_create.stdout"
|
||||
|
||||
- name: Set bucket policy — private (no public access)
|
||||
ansible.builtin.command:
|
||||
cmd: mc anonymous set none local/{{ minio_bucket }}
|
||||
changed_when: false
|
||||
3
infra/ansible/roles/nginx/defaults/main.yml
Normal file
3
infra/ansible/roles/nginx/defaults/main.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
certbot_webroot: /var/www/certbot
|
||||
certbot_email: info@performancewest.net
|
||||
10
infra/ansible/roles/nginx/handlers/main.yml
Normal file
10
infra/ansible/roles/nginx/handlers/main.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
- name: Reload nginx
|
||||
ansible.builtin.systemd:
|
||||
name: nginx
|
||||
state: reloaded
|
||||
|
||||
- name: Restart fail2ban
|
||||
ansible.builtin.systemd:
|
||||
name: fail2ban
|
||||
state: restarted
|
||||
447
infra/ansible/roles/nginx/tasks/main.yml
Normal file
447
infra/ansible/roles/nginx/tasks/main.yml
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
---
|
||||
- name: Install nginx
|
||||
ansible.builtin.apt:
|
||||
name: nginx
|
||||
state: present
|
||||
|
||||
- name: Install certbot and nginx plugin
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- certbot
|
||||
- python3-certbot-nginx
|
||||
state: present
|
||||
|
||||
- name: Install fail2ban
|
||||
ansible.builtin.apt:
|
||||
name: fail2ban
|
||||
state: present
|
||||
|
||||
- name: Create certbot webroot directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ certbot_webroot }}"
|
||||
state: directory
|
||||
owner: www-data
|
||||
group: www-data
|
||||
mode: "0755"
|
||||
|
||||
- name: Create snippets directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/nginx/snippets
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Deploy shared security snippet
|
||||
ansible.builtin.template:
|
||||
src: pw-security.conf.j2
|
||||
dest: /etc/nginx/snippets/pw-security.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
# ── Phase 1: HTTP-only configs for certbot bootstrap ─────────────────────────
|
||||
|
||||
- name: Deploy initial HTTP-only site config
|
||||
ansible.builtin.template:
|
||||
src: pw-site.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-site.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Enable HTTP-only site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-site.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-site.conf
|
||||
state: link
|
||||
|
||||
- name: Remove default nginx site
|
||||
ansible.builtin.file:
|
||||
path: /etc/nginx/sites-enabled/default
|
||||
state: absent
|
||||
|
||||
- name: Reload nginx for HTTP configs
|
||||
ansible.builtin.systemd:
|
||||
name: nginx
|
||||
state: reloaded
|
||||
|
||||
# ── Phase 2: Obtain TLS certificates ─────────────────────────────────────────
|
||||
|
||||
- name: Obtain certificate for performancewest.net
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d performancewest.net -d www.performancewest.net
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/performancewest.net/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for api.performancewest.net
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ api_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ api_domain }}/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for portal.performancewest.net
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ portal_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ portal_domain }}/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for crm.performancewest.net
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ crm_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ crm_domain }}/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for lists.performancewest.net (Listmonk)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ listmonk_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ listmonk_domain }}/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for analytics.performancewest.net
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ analytics_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ analytics_domain }}/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for dev.performancewest.net
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ dev_domain }} -d www.{{ dev_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ dev_domain }}/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for api.dev.performancewest.net
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ dev_api_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ dev_api_domain }}/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for pay.performancewest.net (SHKeeper API)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ shkeeper_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ shkeeper_domain }}/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for crypto.performancewest.net (SHKeeper Admin)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ shkeeper_admin_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ shkeeper_admin_domain }}/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for minio.performancewest.net
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ minio_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ minio_domain }}/fullchain.pem
|
||||
|
||||
- name: Obtain certificate for minio-console.performancewest.net
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
certbot certonly --webroot
|
||||
-w {{ certbot_webroot }}
|
||||
-d {{ minio_console_domain }}
|
||||
--email {{ certbot_email }}
|
||||
--agree-tos --non-interactive
|
||||
creates: /etc/letsencrypt/live/{{ minio_console_domain }}/fullchain.pem
|
||||
|
||||
# ── Phase 3: Deploy TLS configs ───────────────────────────────────────────────
|
||||
|
||||
- name: Deploy TLS config for performancewest.net
|
||||
ansible.builtin.template:
|
||||
src: pw-site-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-site.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Deploy TLS config for api.performancewest.net
|
||||
ansible.builtin.template:
|
||||
src: pw-api-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-api.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Enable API site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-api.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-api.conf
|
||||
state: link
|
||||
|
||||
- name: Deploy TLS config for portal.performancewest.net
|
||||
ansible.builtin.template:
|
||||
src: pw-portal-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-portal.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Enable portal site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-portal.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-portal.conf
|
||||
state: link
|
||||
|
||||
- name: Deploy TLS config for crm.performancewest.net (ERPNext internal CRM)
|
||||
ansible.builtin.template:
|
||||
src: pw-support-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-crm.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Enable CRM site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-crm.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-crm.conf
|
||||
state: link
|
||||
|
||||
- name: Remove deprecated Zammad/Mautic/support site configs
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- /etc/nginx/sites-available/pw-support.conf
|
||||
- /etc/nginx/sites-enabled/pw-support.conf
|
||||
- /etc/nginx/sites-available/pw-mautic.conf
|
||||
- /etc/nginx/sites-enabled/pw-mautic.conf
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Deploy TLS config for lists.performancewest.net (Listmonk)
|
||||
ansible.builtin.template:
|
||||
src: pw-listmonk-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-listmonk.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Enable Listmonk site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-listmonk.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-listmonk.conf
|
||||
state: link
|
||||
|
||||
- name: Deploy TLS config for analytics.performancewest.net
|
||||
ansible.builtin.template:
|
||||
src: pw-analytics-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-analytics.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Enable analytics site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-analytics.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-analytics.conf
|
||||
state: link
|
||||
|
||||
- name: Deploy TLS config for dev.performancewest.net
|
||||
ansible.builtin.template:
|
||||
src: pw-dev-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-dev-site.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Enable dev site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-dev-site.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-dev-site.conf
|
||||
state: link
|
||||
|
||||
- name: Deploy TLS config for api.dev.performancewest.net
|
||||
ansible.builtin.template:
|
||||
src: pw-dev-api-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-dev-api.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Enable dev API site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-dev-api.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-dev-api.conf
|
||||
state: link
|
||||
|
||||
- name: Deploy TLS config for pay.performancewest.net (SHKeeper API)
|
||||
ansible.builtin.template:
|
||||
src: pw-btcpay-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-btcpay.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Enable SHKeeper API site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-btcpay.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-btcpay.conf
|
||||
state: link
|
||||
|
||||
- name: Deploy TLS config for crypto.performancewest.net (SHKeeper Admin)
|
||||
ansible.builtin.template:
|
||||
src: pw-crypto-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-crypto.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Enable SHKeeper Admin site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-crypto.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-crypto.conf
|
||||
state: link
|
||||
|
||||
- name: Deploy TLS config for minio.performancewest.net + console
|
||||
ansible.builtin.template:
|
||||
src: pw-minio-tls.conf.j2
|
||||
dest: /etc/nginx/sites-available/pw-minio.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload nginx
|
||||
|
||||
- name: Enable MinIO site config
|
||||
ansible.builtin.file:
|
||||
src: /etc/nginx/sites-available/pw-minio.conf
|
||||
dest: /etc/nginx/sites-enabled/pw-minio.conf
|
||||
state: link
|
||||
|
||||
# ── Phase 4: Firewall & fail2ban ─────────────────────────────────────────────
|
||||
|
||||
- name: Allow HTTP through UFW
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: "80"
|
||||
proto: tcp
|
||||
comment: HTTP
|
||||
|
||||
- name: Allow HTTPS through UFW
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: "443"
|
||||
proto: tcp
|
||||
comment: HTTPS
|
||||
|
||||
- name: Deploy fail2ban nginx filter
|
||||
ansible.builtin.copy:
|
||||
content: |
|
||||
[Definition]
|
||||
failregex = ^<HOST> .* "(GET|POST|HEAD) .*(\.php|\.asp|wp-admin|wp-login|\.env|\.git).*" (400|403|404|444)
|
||||
ignoreregex =
|
||||
dest: /etc/fail2ban/filter.d/nginx-badbots.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart fail2ban
|
||||
|
||||
- name: Deploy fail2ban nginx jail
|
||||
ansible.builtin.copy:
|
||||
content: |
|
||||
[nginx-badbots]
|
||||
enabled = true
|
||||
port = http,https
|
||||
filter = nginx-badbots
|
||||
logpath = /var/log/nginx/access.log
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
dest: /etc/fail2ban/jail.d/nginx-badbots.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart fail2ban
|
||||
|
||||
- name: Deploy fail2ban pw-api filter
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../../../fail2ban/filter.d/pw-api.conf"
|
||||
dest: /etc/fail2ban/filter.d/pw-api.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart fail2ban
|
||||
|
||||
- name: Deploy fail2ban pw-api jail
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../../../fail2ban/jail.d/pw.conf"
|
||||
dest: /etc/fail2ban/jail.d/pw-api.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart fail2ban
|
||||
|
||||
- name: Enable and start fail2ban
|
||||
ansible.builtin.systemd:
|
||||
name: fail2ban
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Enable and start nginx
|
||||
ansible.builtin.systemd:
|
||||
name: nginx
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# ── Phase 5: Certbot renewal ──────────────────────────────────────────────────
|
||||
|
||||
- name: Set up certbot renewal cron
|
||||
ansible.builtin.cron:
|
||||
name: "Certbot renewal"
|
||||
minute: "30"
|
||||
hour: "3"
|
||||
job: "certbot renew --quiet --post-hook 'systemctl reload nginx'"
|
||||
user: root
|
||||
51
infra/ansible/roles/nginx/templates/pw-analytics-tls.conf.j2
Normal file
51
infra/ansible/roles/nginx/templates/pw-analytics-tls.conf.j2
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# {{ ansible_managed }}
|
||||
# HTTPS config for analytics.performancewest.net (Umami)
|
||||
|
||||
# Redirect HTTP -> HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name analytics.performancewest.net;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://analytics.performancewest.net$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# Umami analytics
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name analytics.performancewest.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/analytics.performancewest.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/analytics.performancewest.net/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
include /etc/nginx/snippets/pw-security.conf;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ umami_port }};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
75
infra/ansible/roles/nginx/templates/pw-api-tls.conf.j2
Normal file
75
infra/ansible/roles/nginx/templates/pw-api-tls.conf.j2
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# {{ ansible_managed }}
|
||||
# HTTPS config for api.performancewest.net
|
||||
|
||||
# Rate limiting zone
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
|
||||
# Redirect HTTP -> HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.performancewest.net;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://api.performancewest.net$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# API server
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name api.performancewest.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/api.performancewest.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.performancewest.net/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
include /etc/nginx/snippets/pw-security.conf;
|
||||
|
||||
# CORS headers
|
||||
add_header Access-Control-Allow-Origin "https://performancewest.net" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Max-Age 86400 always;
|
||||
|
||||
location / {
|
||||
# Handle CORS preflight
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "https://performancewest.net";
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
add_header Content-Length 0;
|
||||
add_header Content-Type text/plain;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Rate limiting
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
proxy_pass http://127.0.0.1:{{ api_port }};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
45
infra/ansible/roles/nginx/templates/pw-btcpay-tls.conf.j2
Normal file
45
infra/ansible/roles/nginx/templates/pw-btcpay-tls.conf.j2
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{{ ansible_managed }}
|
||||
|
||||
# SHKeeper API — pay.performancewest.net
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name {{ shkeeper_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ shkeeper_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ shkeeper_domain }}/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
include /etc/nginx/snippets/pw-security.conf;
|
||||
|
||||
client_max_body_size 10m;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ shkeeper_port }};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ shkeeper_domain }};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
45
infra/ansible/roles/nginx/templates/pw-crypto-tls.conf.j2
Normal file
45
infra/ansible/roles/nginx/templates/pw-crypto-tls.conf.j2
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{{ ansible_managed }}
|
||||
|
||||
# SHKeeper Admin Panel — crypto.performancewest.net
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name {{ shkeeper_admin_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ shkeeper_admin_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ shkeeper_admin_domain }}/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
include /etc/nginx/snippets/pw-security.conf;
|
||||
|
||||
client_max_body_size 10m;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ shkeeper_port }};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ shkeeper_admin_domain }};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
41
infra/ansible/roles/nginx/templates/pw-dev-api-tls.conf.j2
Normal file
41
infra/ansible/roles/nginx/templates/pw-dev-api-tls.conf.j2
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# {{ ansible_managed }}
|
||||
# HTTPS config for api.dev.performancewest.net (dev API — direct access)
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ dev_api_domain }};
|
||||
location /.well-known/acme-challenge/ { root {{ certbot_webroot }}; }
|
||||
location / { return 301 https://{{ dev_api_domain }}$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name {{ dev_api_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ dev_api_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ dev_api_domain }}/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ dev_api_port }};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
52
infra/ansible/roles/nginx/templates/pw-dev-tls.conf.j2
Normal file
52
infra/ansible/roles/nginx/templates/pw-dev-tls.conf.j2
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# {{ ansible_managed }}
|
||||
# HTTPS config for dev.performancewest.net (dev stack — site + api combined)
|
||||
# Dev site runs on port {{ dev_site_port }}, dev API on port {{ dev_api_port }}.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ dev_domain }} www.{{ dev_domain }};
|
||||
location /.well-known/acme-challenge/ { root {{ certbot_webroot }}; }
|
||||
location / { return 301 https://{{ dev_domain }}$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name {{ dev_domain }} www.{{ dev_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ dev_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ dev_domain }}/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
# Proxy /api/* to the dev API container (avoids CORS issues in the browser)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:{{ dev_api_port }}/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ dev_site_port }};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
43
infra/ansible/roles/nginx/templates/pw-listmonk-tls.conf.j2
Normal file
43
infra/ansible/roles/nginx/templates/pw-listmonk-tls.conf.j2
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# {{ ansible_managed }}
|
||||
# HTTPS config for lists.performancewest.net (Listmonk email marketing)
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ listmonk_domain }};
|
||||
location /.well-known/acme-challenge/ { root {{ certbot_webroot }}; }
|
||||
location / { return 301 https://{{ listmonk_domain }}$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name {{ listmonk_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ listmonk_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ listmonk_domain }}/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
# Large body for import files (subscriber CSV uploads)
|
||||
client_max_body_size 50m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ listmonk_port }};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
26
infra/ansible/roles/nginx/templates/pw-mautic-tls.conf.j2
Normal file
26
infra/ansible/roles/nginx/templates/pw-mautic-tls.conf.j2
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Mautic Email Marketing — mail.performancewest.net
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name {{ mautic_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ mautic_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ mautic_domain }}/privkey.pem;
|
||||
|
||||
include /etc/nginx/conf.d/pw-security.conf;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ mautic_port }};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ mautic_domain }};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
97
infra/ansible/roles/nginx/templates/pw-minio-tls.conf.j2
Normal file
97
infra/ansible/roles/nginx/templates/pw-minio-tls.conf.j2
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{{ ansible_managed }}
|
||||
|
||||
# MinIO S3 API — minio.performancewest.net
|
||||
# ACCESS RESTRICTED: only the Windows DocServer VM ({{ docserver_ip }}) and localhost.
|
||||
# Docker containers reach MinIO via the internal Docker network (minio:9000),
|
||||
# not through this nginx vhost. This endpoint is for external clients only.
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name {{ minio_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ minio_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ minio_domain }}/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
# IP allowlist — only the Windows DocServer VM and localhost
|
||||
allow 127.0.0.1;
|
||||
allow ::1;
|
||||
allow {{ docserver_ip }};
|
||||
deny all;
|
||||
|
||||
# Large body for document uploads
|
||||
client_max_body_size 100m;
|
||||
|
||||
# Disable buffering for S3 streaming
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ minio_port }};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
|
||||
# MinIO Console — minio-console.performancewest.net
|
||||
# Same IP restriction: admin console is never public.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name {{ minio_console_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ minio_console_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ minio_console_domain }}/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
# IP allowlist
|
||||
allow 127.0.0.1;
|
||||
allow ::1;
|
||||
allow {{ docserver_ip }};
|
||||
deny all;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ minio_console_port }};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ minio_domain }} {{ minio_console_domain }};
|
||||
location /.well-known/acme-challenge/ { root {{ certbot_webroot }}; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
47
infra/ansible/roles/nginx/templates/pw-portal-tls.conf.j2
Normal file
47
infra/ansible/roles/nginx/templates/pw-portal-tls.conf.j2
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# {{ ansible_managed }}
|
||||
# HTTPS config for portal.performancewest.net (ERPNext customer portal)
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ portal_domain }};
|
||||
location /.well-known/acme-challenge/ { root {{ certbot_webroot }}; }
|
||||
location / { return 301 https://{{ portal_domain }}$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name {{ portal_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ portal_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ portal_domain }}/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ erpnext_port }};
|
||||
proxy_http_version 1.1;
|
||||
# Must match the Frappe site name so the right site is served
|
||||
proxy_set_header Host {{ domain }};
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host {{ portal_domain }};
|
||||
# WebSocket support (ERPNext real-time)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 120s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
48
infra/ansible/roles/nginx/templates/pw-security.conf.j2
Normal file
48
infra/ansible/roles/nginx/templates/pw-security.conf.j2
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# {{ ansible_managed }}
|
||||
# Shared security snippet - included by all server blocks
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Block common attack paths
|
||||
location ~* \.(php|asp|aspx|cgi|pl)$ {
|
||||
return 444;
|
||||
}
|
||||
|
||||
location ~* /(wp-admin|wp-login|wp-content|wp-includes|wordpress) {
|
||||
return 444;
|
||||
}
|
||||
|
||||
location ~ /\.git {
|
||||
return 444;
|
||||
}
|
||||
|
||||
location ~ /\.env {
|
||||
return 444;
|
||||
}
|
||||
|
||||
location ~ /\.ht {
|
||||
return 444;
|
||||
}
|
||||
|
||||
location ~* /(phpmyadmin|pma|myadmin|mysql|adminer) {
|
||||
return 444;
|
||||
}
|
||||
|
||||
location ~* /(admin|administrator|login\.action|struts) {
|
||||
return 444;
|
||||
}
|
||||
|
||||
# Block hidden files and directories (except .well-known)
|
||||
location ~ /\.(?!well-known) {
|
||||
return 444;
|
||||
}
|
||||
|
||||
# Block backup and config files
|
||||
location ~* \.(bak|config|sql|fla|ini|log|sh|inc|swp|dist|old|save)$ {
|
||||
return 444;
|
||||
}
|
||||
87
infra/ansible/roles/nginx/templates/pw-site-tls.conf.j2
Normal file
87
infra/ansible/roles/nginx/templates/pw-site-tls.conf.j2
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# {{ ansible_managed }}
|
||||
# HTTPS config for performancewest.net
|
||||
|
||||
# Redirect HTTP -> HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name performancewest.net www.performancewest.net;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://performancewest.net$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect www -> apex
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name www.performancewest.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/performancewest.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/performancewest.net/privkey.pem;
|
||||
|
||||
return 301 https://performancewest.net$request_uri;
|
||||
}
|
||||
|
||||
# Main site
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name performancewest.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/performancewest.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/performancewest.net/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# HSTS - 1 year, include subdomains
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
include /etc/nginx/snippets/pw-security.conf;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
font/woff2;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ site_port }};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Cache static assets
|
||||
proxy_cache_valid 200 1h;
|
||||
proxy_buffering on;
|
||||
}
|
||||
|
||||
# Long cache for immutable assets
|
||||
location /_astro/ {
|
||||
proxy_pass http://127.0.0.1:{{ site_port }};
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
}
|
||||
20
infra/ansible/roles/nginx/templates/pw-site.conf.j2
Normal file
20
infra/ansible/roles/nginx/templates/pw-site.conf.j2
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# {{ ansible_managed }}
|
||||
# HTTP-only config for initial certbot provisioning
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name performancewest.net www.performancewest.net;
|
||||
|
||||
# Certbot ACME challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{ certbot_webroot }};
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ site_port }};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
34
infra/ansible/roles/nginx/templates/pw-support-tls.conf.j2
Normal file
34
infra/ansible/roles/nginx/templates/pw-support-tls.conf.j2
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# ERPNext CRM — crm.performancewest.net
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name {{ crm_domain }};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/{{ crm_domain }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ crm_domain }}/privkey.pem;
|
||||
|
||||
include /etc/nginx/conf.d/pw-security.conf;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{ erpnext_port }};
|
||||
proxy_set_header Host {{ domain }}; # Frappe matches site by Host header — must match site dir name (performancewest.net)
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support (for ERPNext real-time)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{ crm_domain }};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
3
infra/ansible/roles/postgresql/defaults/main.yml
Normal file
3
infra/ansible/roles/postgresql/defaults/main.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
pg_backup_dir: /opt/backups/postgresql
|
||||
pg_backup_retention_days: 30
|
||||
58
infra/ansible/roles/postgresql/tasks/main.yml
Normal file
58
infra/ansible/roles/postgresql/tasks/main.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
- name: Create PostgreSQL data directory
|
||||
ansible.builtin.file:
|
||||
path: /opt/performancewest/pgdata
|
||||
state: directory
|
||||
owner: "{{ deploy_user }}"
|
||||
group: "{{ deploy_user }}"
|
||||
mode: "0700"
|
||||
|
||||
- name: Create backup directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ pg_backup_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Start PostgreSQL container
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d postgres
|
||||
chdir: /opt/performancewest
|
||||
register: pg_start
|
||||
changed_when: "'Started' in pg_start.stderr or 'Creating' in pg_start.stderr"
|
||||
|
||||
- name: Wait for PostgreSQL to accept connections
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
docker compose exec -T postgres
|
||||
pg_isready -U {{ pg_user }} -d {{ pg_database }}
|
||||
chdir: /opt/performancewest
|
||||
register: pg_ready
|
||||
retries: 12
|
||||
delay: 5
|
||||
until: pg_ready.rc == 0
|
||||
changed_when: false
|
||||
|
||||
- name: Run database migrations
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose exec -T api npm run db:migrate
|
||||
chdir: /opt/performancewest
|
||||
register: db_migrate
|
||||
changed_when: "'migrated' in db_migrate.stdout"
|
||||
|
||||
- name: Deploy PostgreSQL backup script
|
||||
ansible.builtin.template:
|
||||
src: pg-backup.sh.j2
|
||||
dest: /usr/local/bin/pg-backup.sh
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Set up daily PostgreSQL backup cron
|
||||
ansible.builtin.cron:
|
||||
name: "Daily PostgreSQL backup"
|
||||
minute: "0"
|
||||
hour: "2"
|
||||
job: "/usr/local/bin/pg-backup.sh >> /var/log/pg-backup.log 2>&1"
|
||||
user: root
|
||||
34
infra/ansible/roles/postgresql/templates/pg-backup.sh.j2
Normal file
34
infra/ansible/roles/postgresql/templates/pg-backup.sh.j2
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env bash
|
||||
# {{ ansible_managed }}
|
||||
# Daily PostgreSQL backup script
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="{{ pg_backup_dir }}"
|
||||
RETENTION_DAYS="{{ pg_backup_retention_days }}"
|
||||
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
BACKUP_FILE="${BACKUP_DIR}/pw_db_${TIMESTAMP}.sql.gz"
|
||||
|
||||
echo "[$(date --iso-8601=seconds)] Starting PostgreSQL backup..."
|
||||
|
||||
# Dump the database from the Docker container and compress
|
||||
docker compose -f /opt/performancewest/docker-compose.yml \
|
||||
exec -T api-postgres \
|
||||
pg_dump -U {{ pg_user }} -d {{ pg_database }} --no-owner --no-privileges \
|
||||
| gzip > "${BACKUP_FILE}"
|
||||
|
||||
# Verify the backup file is not empty
|
||||
if [ ! -s "${BACKUP_FILE}" ]; then
|
||||
echo "[$(date --iso-8601=seconds)] ERROR: Backup file is empty, removing."
|
||||
rm -f "${BACKUP_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_SIZE="$(du -h "${BACKUP_FILE}" | cut -f1)"
|
||||
echo "[$(date --iso-8601=seconds)] Backup completed: ${BACKUP_FILE} (${BACKUP_SIZE})"
|
||||
|
||||
# Prune backups older than retention period
|
||||
PRUNED="$(find "${BACKUP_DIR}" -name "pw_db_*.sql.gz" -type f -mtime +${RETENTION_DAYS} -print -delete | wc -l)"
|
||||
echo "[$(date --iso-8601=seconds)] Pruned ${PRUNED} backup(s) older than ${RETENTION_DAYS} days."
|
||||
|
||||
echo "[$(date --iso-8601=seconds)] Backup process finished."
|
||||
31
infra/ansible/roles/sftpgo/defaults/main.yml
Normal file
31
infra/ansible/roles/sftpgo/defaults/main.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
# SFTPGo role defaults — Performance West CDR ingestion.
|
||||
|
||||
sftpgo_image: "drakkan/sftpgo:edge-alpine-slim"
|
||||
sftpgo_sftp_port: 2022 # SFTP
|
||||
sftpgo_ftps_port: 990 # FTPS implicit
|
||||
sftpgo_passive_port_min: 50000
|
||||
sftpgo_passive_port_max: 50100
|
||||
sftpgo_admin_port: 8080 # internal-only admin API + web UI
|
||||
sftpgo_hostname: "cdr.performancewest.net"
|
||||
|
||||
# MinIO backend for user home directories (cdr-uploads/{customer_id}/raw/)
|
||||
sftpgo_minio_endpoint: "{{ minio_endpoint }}"
|
||||
sftpgo_minio_bucket: "{{ minio_bucket }}"
|
||||
sftpgo_minio_access_key: "{{ minio_access_key }}"
|
||||
sftpgo_minio_secret_key: "{{ minio_secret_key }}"
|
||||
|
||||
# Postgres for SFTPGo's own data (users/logins/sessions). Reuses the
|
||||
# existing cluster; SFTPGo gets its own database.
|
||||
sftpgo_pg_host: "postgres"
|
||||
sftpgo_pg_port: 5432
|
||||
sftpgo_pg_database: "sftpgo"
|
||||
sftpgo_pg_username: "sftpgo"
|
||||
sftpgo_pg_password: "{{ sftpgo_pg_password_vault | default('CHANGEME_provision_via_vault') }}"
|
||||
|
||||
# Admin credentials for the SFTPGo REST API.
|
||||
sftpgo_admin_user: "pw-admin"
|
||||
sftpgo_admin_password: "{{ sftpgo_admin_password_vault | default('CHANGEME_provision_via_vault') }}"
|
||||
|
||||
# Per-user default quota (overridable at provisioning time)
|
||||
sftpgo_default_quota_bytes: 5368709120 # 5 GB
|
||||
67
infra/ansible/roles/sftpgo/tasks/main.yml
Normal file
67
infra/ansible/roles/sftpgo/tasks/main.yml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
# Performance West — SFTPGo role
|
||||
# Deploys the SFTPGo SFTP/FTPS server configured to use MinIO as backend
|
||||
# storage and Postgres as the user database. CDR-ingestion customers
|
||||
# opt-in via the portal; the puller's sftpgo_provisioner worker calls
|
||||
# the admin REST API to provision/deprovision users.
|
||||
|
||||
- name: Ensure SFTPGo config directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ project_dir }}/sftpgo"
|
||||
state: directory
|
||||
mode: "0750"
|
||||
|
||||
- name: Render sftpgo.json
|
||||
ansible.builtin.template:
|
||||
src: sftpgo.json.j2
|
||||
dest: "{{ project_dir }}/sftpgo/sftpgo.json"
|
||||
mode: "0640"
|
||||
notify: Restart SFTPGo
|
||||
|
||||
- name: Ensure sftpgo database + user (Postgres)
|
||||
community.postgresql.postgresql_db:
|
||||
name: "{{ sftpgo_pg_database }}"
|
||||
login_host: "{{ sftpgo_pg_host }}"
|
||||
port: "{{ sftpgo_pg_port }}"
|
||||
login_user: postgres
|
||||
state: present
|
||||
|
||||
- name: Ensure sftpgo Postgres user
|
||||
community.postgresql.postgresql_user:
|
||||
db: "{{ sftpgo_pg_database }}"
|
||||
name: "{{ sftpgo_pg_username }}"
|
||||
password: "{{ sftpgo_pg_password }}"
|
||||
priv: "ALL"
|
||||
login_host: "{{ sftpgo_pg_host }}"
|
||||
login_user: postgres
|
||||
state: present
|
||||
|
||||
- name: Start SFTPGo container via docker-compose
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ project_dir }}"
|
||||
services:
|
||||
- sftpgo
|
||||
state: present
|
||||
|
||||
- name: Wait for SFTPGo admin API to be ready
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ sftpgo_admin_port }}/healthz"
|
||||
status_code: 200
|
||||
timeout: 5
|
||||
register: sftpgo_health
|
||||
retries: 12
|
||||
delay: 5
|
||||
until: sftpgo_health.status == 200
|
||||
|
||||
- name: Create initial SFTPGo admin user (idempotent — errors on exists)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ sftpgo_admin_port }}/api/v2/admins"
|
||||
method: POST
|
||||
body_format: json
|
||||
body:
|
||||
username: "{{ sftpgo_admin_user }}"
|
||||
password: "{{ sftpgo_admin_password }}"
|
||||
status: 1
|
||||
permissions: ["*"]
|
||||
description: "Performance West provisioning admin"
|
||||
status_code: [201, 409] # 409 = already exists
|
||||
79
infra/ansible/roles/sftpgo/templates/sftpgo.json.j2
Normal file
79
infra/ansible/roles/sftpgo/templates/sftpgo.json.j2
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"sftpd": {
|
||||
"bindings": [
|
||||
{
|
||||
"address": "0.0.0.0",
|
||||
"port": {{ sftpgo_sftp_port }}
|
||||
}
|
||||
],
|
||||
"max_auth_tries": 3,
|
||||
"banner": "Performance West CDR Ingestion — authorized use only",
|
||||
"host_keys": ["/var/lib/sftpgo/host_keys/ssh_host_ed25519_key",
|
||||
"/var/lib/sftpgo/host_keys/ssh_host_rsa_key"]
|
||||
},
|
||||
"ftpd": {
|
||||
"bindings": [
|
||||
{
|
||||
"address": "0.0.0.0",
|
||||
"port": {{ sftpgo_ftps_port }},
|
||||
"apply_proxy_config": false,
|
||||
"tls_mode": 2,
|
||||
"force_passive_ip": "",
|
||||
"passive_ip_overrides": [],
|
||||
"passive_connections_security": 0
|
||||
}
|
||||
],
|
||||
"passive_port_range": {
|
||||
"start": {{ sftpgo_passive_port_min }},
|
||||
"end": {{ sftpgo_passive_port_max }}
|
||||
},
|
||||
"certificate_file": "/var/lib/sftpgo/certs/fullchain.pem",
|
||||
"certificate_key_file": "/var/lib/sftpgo/certs/privkey.pem"
|
||||
},
|
||||
"httpd": {
|
||||
"bindings": [
|
||||
{
|
||||
"address": "127.0.0.1",
|
||||
"port": {{ sftpgo_admin_port }},
|
||||
"enable_web_admin": true,
|
||||
"enable_web_client": false,
|
||||
"enable_rest_api": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"data_provider": {
|
||||
"driver": "postgresql",
|
||||
"name": "{{ sftpgo_pg_database }}",
|
||||
"host": "{{ sftpgo_pg_host }}",
|
||||
"port": {{ sftpgo_pg_port }},
|
||||
"username": "{{ sftpgo_pg_username }}",
|
||||
"password": "{{ sftpgo_pg_password }}",
|
||||
"sslmode": 0,
|
||||
"track_quota": 2
|
||||
},
|
||||
"common": {
|
||||
"idle_timeout": 15,
|
||||
"upload_mode": 0,
|
||||
"actions": {
|
||||
"execute_on": ["upload"],
|
||||
"execute_sync": []
|
||||
}
|
||||
},
|
||||
"filesystems": [
|
||||
{
|
||||
"fs_type": "s3",
|
||||
"name": "minio_default",
|
||||
"params": {
|
||||
"bucket": "{{ sftpgo_minio_bucket }}",
|
||||
"endpoint": "{{ sftpgo_minio_endpoint }}",
|
||||
"region": "us-east-1",
|
||||
"access_key": "{{ sftpgo_minio_access_key }}",
|
||||
"access_secret": {
|
||||
"status": "Plain",
|
||||
"payload": "{{ sftpgo_minio_secret_key }}"
|
||||
},
|
||||
"force_path_style": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
29
infra/ansible/roles/shkeeper/defaults/main.yml
Normal file
29
infra/ansible/roles/shkeeper/defaults/main.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
# SHKeeper — self-hosted crypto payment processor
|
||||
# Deployed via Helm chart onto k3s (lightweight Kubernetes)
|
||||
|
||||
shkeeper_port: 5000
|
||||
shkeeper_domain: pay.performancewest.net
|
||||
shkeeper_admin_domain: crypto.performancewest.net
|
||||
|
||||
# Cryptocurrencies to enable
|
||||
shkeeper_cryptos:
|
||||
btc: true
|
||||
eth: true
|
||||
matic: true
|
||||
trx: true
|
||||
bnb: true
|
||||
ltc: true
|
||||
|
||||
# k3s options
|
||||
k3s_install_url: https://get.k3s.io
|
||||
k3s_exec_args: "--docker --disable=traefik"
|
||||
|
||||
# Helm repos
|
||||
shkeeper_helm_repo: https://vsys-host.github.io/helm-charts
|
||||
shkeeper_secret_gen_repo: https://helm.mittwald.de
|
||||
|
||||
# Helm release
|
||||
shkeeper_helm_release: shkeeper
|
||||
shkeeper_helm_namespace: default
|
||||
shkeeper_storage_class: local-path
|
||||
163
infra/ansible/roles/shkeeper/tasks/main.yml
Normal file
163
infra/ansible/roles/shkeeper/tasks/main.yml
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
---
|
||||
# Performance West — SHKeeper role
|
||||
# Installs k3s (lightweight Kubernetes), Helm, and deploys SHKeeper
|
||||
# via the official Helm chart for crypto payment processing.
|
||||
#
|
||||
# SHKeeper supports: BTC, ETH, USDC/USDT (ERC-20), Polygon, Tron, BNB, LTC, DOGE, XMR, SOL, AVAX, XRP
|
||||
# All free, open-source, non-custodial.
|
||||
#
|
||||
# Architecture:
|
||||
# k3s manages SHKeeper pods (crypto daemons + Flask API + MariaDB)
|
||||
# nginx on the host proxies pay.performancewest.net → k3s NodePort/LoadBalancer
|
||||
# ERPNext frappe_crypto app calls SHKeeper REST API to create invoices
|
||||
# SHKeeper webhooks POST back to ERPNext on payment confirmation
|
||||
|
||||
# ── k3s installation ──────────────────────────────────────────────────────────
|
||||
|
||||
- name: Check if k3s is already installed
|
||||
ansible.builtin.stat:
|
||||
path: /usr/local/bin/k3s
|
||||
register: k3s_binary
|
||||
|
||||
- name: Install k3s with Docker runtime
|
||||
ansible.builtin.shell: |
|
||||
curl -sfL {{ k3s_install_url }} | INSTALL_K3S_EXEC="{{ k3s_exec_args }}" sh -
|
||||
args:
|
||||
creates: /usr/local/bin/k3s
|
||||
when: not k3s_binary.stat.exists
|
||||
|
||||
- name: Wait for k3s to be ready
|
||||
ansible.builtin.command: k3s kubectl get nodes
|
||||
register: k3s_nodes
|
||||
retries: 12
|
||||
delay: 5
|
||||
until: "'Ready' in k3s_nodes.stdout"
|
||||
changed_when: false
|
||||
|
||||
- name: Ensure deploy user has kubectl access
|
||||
block:
|
||||
- name: Create .kube directory for deploy user
|
||||
ansible.builtin.file:
|
||||
path: "/home/{{ deploy_user }}/.kube"
|
||||
state: directory
|
||||
owner: "{{ deploy_user }}"
|
||||
group: "{{ deploy_user }}"
|
||||
mode: "0700"
|
||||
|
||||
- name: Copy k3s kubeconfig for deploy user
|
||||
ansible.builtin.copy:
|
||||
src: /etc/rancher/k3s/k3s.yaml
|
||||
dest: "/home/{{ deploy_user }}/.kube/config"
|
||||
remote_src: true
|
||||
owner: "{{ deploy_user }}"
|
||||
group: "{{ deploy_user }}"
|
||||
mode: "0600"
|
||||
|
||||
# ── Helm installation ─────────────────────────────────────────────────────────
|
||||
|
||||
- name: Check if helm is already installed
|
||||
ansible.builtin.stat:
|
||||
path: /usr/local/bin/helm
|
||||
register: helm_binary
|
||||
|
||||
- name: Install Helm 3
|
||||
ansible.builtin.shell: |
|
||||
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||
args:
|
||||
creates: /usr/local/bin/helm
|
||||
when: not helm_binary.stat.exists
|
||||
|
||||
# ── Helm repos ────────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Add SHKeeper Helm repo
|
||||
kubernetes.core.helm_repository:
|
||||
name: vsys-host
|
||||
repo_url: "{{ shkeeper_helm_repo }}"
|
||||
become: true
|
||||
become_user: "{{ deploy_user }}"
|
||||
environment:
|
||||
KUBECONFIG: "/home/{{ deploy_user }}/.kube/config"
|
||||
|
||||
- name: Add mittwald Helm repo (kubernetes-secret-generator)
|
||||
kubernetes.core.helm_repository:
|
||||
name: mittwald
|
||||
repo_url: "{{ shkeeper_secret_gen_repo }}"
|
||||
become: true
|
||||
become_user: "{{ deploy_user }}"
|
||||
environment:
|
||||
KUBECONFIG: "/home/{{ deploy_user }}/.kube/config"
|
||||
|
||||
# ── Deploy kubernetes-secret-generator (required by SHKeeper) ─────────────────
|
||||
|
||||
- name: Install kubernetes-secret-generator
|
||||
kubernetes.core.helm:
|
||||
name: kubernetes-secret-generator
|
||||
chart_ref: mittwald/kubernetes-secret-generator
|
||||
release_namespace: default
|
||||
state: present
|
||||
update_repo_cache: true
|
||||
become: true
|
||||
become_user: "{{ deploy_user }}"
|
||||
environment:
|
||||
KUBECONFIG: "/home/{{ deploy_user }}/.kube/config"
|
||||
|
||||
# ── Deploy SHKeeper values.yaml ──────────────────────────────────────────────
|
||||
|
||||
- name: Deploy SHKeeper Helm values.yaml
|
||||
ansible.builtin.template:
|
||||
src: shkeeper-values.yaml.j2
|
||||
dest: "{{ project_dir }}/shkeeper-values.yaml"
|
||||
owner: "{{ deploy_user }}"
|
||||
group: "{{ deploy_user }}"
|
||||
mode: "0644"
|
||||
|
||||
# ── Deploy SHKeeper ──────────────────────────────────────────────────────────
|
||||
|
||||
- name: Install/upgrade SHKeeper via Helm
|
||||
kubernetes.core.helm:
|
||||
name: "{{ shkeeper_helm_release }}"
|
||||
chart_ref: vsys-host/shkeeper
|
||||
release_namespace: "{{ shkeeper_helm_namespace }}"
|
||||
state: present
|
||||
values_files:
|
||||
- "{{ project_dir }}/shkeeper-values.yaml"
|
||||
update_repo_cache: true
|
||||
become: true
|
||||
become_user: "{{ deploy_user }}"
|
||||
environment:
|
||||
KUBECONFIG: "/home/{{ deploy_user }}/.kube/config"
|
||||
|
||||
- name: Wait for SHKeeper pod to be ready
|
||||
ansible.builtin.command: >
|
||||
k3s kubectl -n shkeeper wait --for=condition=ready pod
|
||||
-l app=shkeeper --timeout=300s
|
||||
register: shkeeper_ready
|
||||
retries: 3
|
||||
delay: 10
|
||||
until: shkeeper_ready.rc == 0
|
||||
changed_when: false
|
||||
ignore_errors: true
|
||||
|
||||
# ── UFW: allow k3s NodePort range ────────────────────────────────────────────
|
||||
|
||||
- name: Allow SHKeeper NodePort access from localhost only
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: "{{ shkeeper_port }}"
|
||||
proto: tcp
|
||||
from_ip: 127.0.0.1
|
||||
comment: "SHKeeper web UI (k3s NodePort, localhost only)"
|
||||
|
||||
# ── Remove stale Bitcart Docker containers if present ────────────────────────
|
||||
|
||||
- name: Stop and remove Bitcart Docker containers (replaced by SHKeeper)
|
||||
community.docker.docker_container:
|
||||
name: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- performancewest-bitcart-1
|
||||
- performancewest-bitcart-worker-1
|
||||
- performancewest-bitcart-admin-1
|
||||
- performancewest-bitcart-postgres-1
|
||||
- performancewest-bitcart-redis-1
|
||||
ignore_errors: true
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
{{ ansible_managed | comment }}
|
||||
#
|
||||
# SHKeeper Helm Chart Values — Performance West
|
||||
# Deployed at: {{ shkeeper_domain }}
|
||||
# Admin panel: {{ shkeeper_admin_domain }}
|
||||
#
|
||||
|
||||
storageClassName: {{ shkeeper_storage_class }}
|
||||
|
||||
{% if shkeeper_cryptos.btc | default(false) %}
|
||||
#
|
||||
# Bitcoin (BTC) — Electrum-based daemon
|
||||
#
|
||||
btc:
|
||||
enabled: true
|
||||
{% endif %}
|
||||
|
||||
{% if shkeeper_cryptos.eth | default(false) %}
|
||||
#
|
||||
# Ethereum (ETH) + ERC-20 tokens (USDC, USDT, DAI)
|
||||
#
|
||||
eth:
|
||||
enabled: true
|
||||
{% endif %}
|
||||
|
||||
{% if shkeeper_cryptos.matic | default(false) %}
|
||||
#
|
||||
# Polygon (MATIC) + ERC-20 tokens on Polygon
|
||||
#
|
||||
matic:
|
||||
enabled: true
|
||||
{% endif %}
|
||||
|
||||
{% if shkeeper_cryptos.trx | default(false) %}
|
||||
#
|
||||
# Tron (TRX) + TRC-20 tokens (USDT, USDC)
|
||||
#
|
||||
trx:
|
||||
enabled: true
|
||||
{% endif %}
|
||||
|
||||
{% if shkeeper_cryptos.bnb | default(false) %}
|
||||
#
|
||||
# BNB Smart Chain + BEP-20 tokens
|
||||
#
|
||||
bnb:
|
||||
enabled: true
|
||||
{% endif %}
|
||||
|
||||
{% if shkeeper_cryptos.ltc | default(false) %}
|
||||
#
|
||||
# Litecoin (LTC)
|
||||
#
|
||||
ltc:
|
||||
enabled: true
|
||||
{% endif %}
|
||||
|
||||
{% if shkeeper_cryptos.doge | default(false) %}
|
||||
#
|
||||
# Dogecoin (DOGE)
|
||||
#
|
||||
doge:
|
||||
enabled: true
|
||||
{% endif %}
|
||||
|
||||
{% if shkeeper_cryptos.xmr | default(false) %}
|
||||
#
|
||||
# Monero (XMR) — requires full node
|
||||
#
|
||||
monero:
|
||||
enabled: true
|
||||
fullnode:
|
||||
enabled: true
|
||||
{% endif %}
|
||||
2
infra/ansible/roles/site/defaults/main.yml
Normal file
2
infra/ansible/roles/site/defaults/main.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
---
|
||||
site_port: 4322
|
||||
43
infra/ansible/roles/site/tasks/main.yml
Normal file
43
infra/ansible/roles/site/tasks/main.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
- name: Create site deployment directory
|
||||
ansible.builtin.file:
|
||||
path: /opt/performancewest/site
|
||||
state: directory
|
||||
owner: "{{ deploy_user }}"
|
||||
group: "{{ deploy_user }}"
|
||||
mode: "0755"
|
||||
|
||||
- name: Sync site source code
|
||||
ansible.posix.synchronize:
|
||||
src: "{{ playbook_dir }}/../../site/"
|
||||
dest: /opt/performancewest/site/
|
||||
delete: true
|
||||
rsync_opts:
|
||||
- "--exclude=node_modules"
|
||||
- "--exclude=dist"
|
||||
- "--exclude=.astro"
|
||||
register: site_sync
|
||||
|
||||
- name: Build site container
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose build site
|
||||
chdir: /opt/performancewest
|
||||
register: site_build
|
||||
changed_when: "'writing image' in site_build.stderr"
|
||||
|
||||
- name: Start site container
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d site
|
||||
chdir: /opt/performancewest
|
||||
register: site_start
|
||||
changed_when: "'Started' in site_start.stderr or 'Creating' in site_start.stderr"
|
||||
|
||||
- name: Wait for site to be reachable
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ site_port }}/"
|
||||
status_code: 200
|
||||
timeout: 5
|
||||
register: site_health
|
||||
retries: 12
|
||||
delay: 5
|
||||
until: site_health.status == 200
|
||||
159
infra/ansible/roles/worker-crons/defaults/main.yml
Normal file
159
infra/ansible/roles/worker-crons/defaults/main.yml
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
---
|
||||
# worker-crons defaults
|
||||
#
|
||||
# Each entry in `worker_crons` deploys a systemd .service + .timer pair.
|
||||
# Times use systemd OnCalendar syntax (see `man systemd.time`).
|
||||
#
|
||||
# The service runs inside the workers container via
|
||||
# docker compose exec -T workers python -m <module>
|
||||
#
|
||||
# Why systemd timers not crond: timers log to journald (queryable with
|
||||
# `journalctl -u <name>.service`), support OnBootSec/Persistent for catch-up
|
||||
# after a downed host, and inherit the docker daemon restart policy
|
||||
# without additional tooling.
|
||||
|
||||
project_dir: /opt/performancewest
|
||||
|
||||
worker_crons:
|
||||
# USF quarterly factor — polls USAC daily at 09:00 Chicago local.
|
||||
- name: pw-usf-factor-monitor
|
||||
description: Poll USAC for new USF quarterly contribution factor
|
||||
module: scripts.workers.usf_factor_monitor
|
||||
on_calendar: "*-*-* 14:00:00 UTC" # 09:00 CT
|
||||
persistent: true
|
||||
|
||||
# De minimis factor presence — 3am daily, alerts if missing.
|
||||
- name: pw-deminimis-factor-check
|
||||
description: Alert if fcc_deminimis_factors missing for current/next year
|
||||
module: scripts.workers.deminimis_factor_check
|
||||
on_calendar: "*-*-* 03:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# Cold wallet sweep — every 30 min.
|
||||
- name: pw-cold-wallet-sweep
|
||||
description: Sweep SHKeeper hot-wallet excess to cold wallet
|
||||
module: scripts.workers.cold_wallet_sweeper
|
||||
on_calendar: "*-*-* *:00,30:00 UTC"
|
||||
persistent: false
|
||||
|
||||
# Crypto payment worker — every 60s while we have pending jobs.
|
||||
- name: pw-crypto-payment-worker
|
||||
description: Advance crypto_payment_jobs state machine
|
||||
module: scripts.workers.crypto_payment_worker
|
||||
on_calendar: "*-*-* *:*:00 UTC"
|
||||
persistent: false
|
||||
|
||||
# Relay deposit monitor — every 5 min, polls IMAP for Relay alerts.
|
||||
- name: pw-relay-deposit-monitor
|
||||
description: Parse Relay email alerts into relay_deposits
|
||||
module: scripts.workers.relay_deposit_monitor
|
||||
on_calendar: "*-*-* *:00/5:00 UTC"
|
||||
persistent: false
|
||||
|
||||
# Commission worker — 02:00 daily (flips 14-day-eligible rows to eligible).
|
||||
- name: pw-commission-worker
|
||||
description: Flip commission_ledger rows to eligible after 14-day holdback
|
||||
module: scripts.workers.commission_worker
|
||||
on_calendar: "*-*-* 02:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# Renewal worker — 04:00 daily.
|
||||
- name: pw-renewal-worker
|
||||
description: Send renewal reminders + auto-renew RA/annual report/CDR
|
||||
module: scripts.workers.renewal_worker
|
||||
on_calendar: "*-*-* 04:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# CDR retention sweeper — 05:00 daily (purges past-retention CDR).
|
||||
- name: pw-cdr-retention
|
||||
description: Purge CDR older than the per-profile retention window
|
||||
module: scripts.workers.cdr_retention_sweeper
|
||||
on_calendar: "*-*-* 05:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# CDR unlock nudge — 10:00 daily (emails customers with locked studies).
|
||||
- name: pw-cdr-unlock-nudge
|
||||
description: Nudge customers whose CDR study is locked behind paywall
|
||||
module: scripts.workers.cdr_unlock_nudge
|
||||
on_calendar: "*-*-* 15:00:00 UTC" # 10:00 CT
|
||||
persistent: true
|
||||
|
||||
# Payment reminder — 11:00 daily.
|
||||
- name: pw-payment-reminder
|
||||
description: Remind customers with unpaid compliance orders
|
||||
module: scripts.workers.payment_reminder
|
||||
on_calendar: "*-*-* 16:00:00 UTC" # 11:00 CT
|
||||
persistent: true
|
||||
|
||||
# RMD removed scraper — weekly, Wednesday 08:00 (tracks FCC RMD removals).
|
||||
- name: pw-fcc-rmd-removed
|
||||
description: Scrape FCC public list of RMD-removed carriers
|
||||
module: scripts.workers.fcc_rmd_removed_scraper
|
||||
on_calendar: "Wed *-*-* 13:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# Client email processor — every 15 min, IMAPs regulatory mailboxes for
|
||||
# CRTC pipeline events (incoming agency replies, domain activation, etc.).
|
||||
- name: pw-client-email-processor
|
||||
description: Parse regulatory mailbox replies for CRTC pipeline advancement
|
||||
module: scripts.workers.client_email_processor
|
||||
on_calendar: "*-*-* *:00/15:00 UTC"
|
||||
persistent: false
|
||||
|
||||
# AMB location scraper — daily 06:00 UTC, pulls current per-mailbox pricing
|
||||
# and sold-out status for the CRTC AMB picker.
|
||||
- name: pw-amb-location-scraper
|
||||
description: Scrape current Anytime Mailbox location + pricing catalog
|
||||
module: scripts.workers.amb_location_scraper
|
||||
on_calendar: "*-*-* 06:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# Compliance alert mailing list — weekly Sunday 06:00 UTC.
|
||||
# Queries local DB for carriers behind on filings and upserts to Listmonk.
|
||||
- name: pw-compliance-alert-list
|
||||
description: Update Listmonk compliance alert mailing list from local FCC data
|
||||
module: scripts.workers.compliance_alert_list
|
||||
on_calendar: "Sun *-*-* 06:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# Entity cache refresh — daily 07:00 UTC (2am CT).
|
||||
# Downloads business entity data from state Socrata APIs (CO, IA, CT, OR, NY)
|
||||
# into entity_cache for corporation status checks.
|
||||
- name: pw-entity-cache-refresh
|
||||
description: Refresh entity_cache from state SOS open data portals
|
||||
module: scripts.formation.bulk_download --all
|
||||
on_calendar: "*-*-* 07:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# Multi-state entity scraper — daily 08:00 UTC (3am CT).
|
||||
# Scrapes state SOS portals via Playwright for states without bulk APIs.
|
||||
# Covers 44 states (excludes CO, IA, CT, OR, NY, FL which use bulk data).
|
||||
- name: pw-entity-scraper
|
||||
description: Scrape business entities from state SOS portals via Playwright
|
||||
module: scripts.workers.entity_scraper --all
|
||||
on_calendar: "*-*-* 08:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# Florida entity downloader — daily 07:30 UTC.
|
||||
# Downloads daily diffs from FL Sunbiz SFTP (free public access).
|
||||
- name: pw-fl-entity-downloader
|
||||
description: Download Florida Sunbiz corporation data via SFTP
|
||||
module: scripts.workers.fl_entity_downloader --daily
|
||||
on_calendar: "*-*-* 07:30:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# RMD email scraper — daily 09:00 UTC (4am CT).
|
||||
# Scrapes contact emails from FCC ServiceNow SP API for RMD carriers.
|
||||
- name: pw-rmd-email-scraper
|
||||
description: Scrape RMD contact emails from FCC ServiceNow portal
|
||||
module: scripts.workers.fcc_rmd_scraper --phase scrape --limit 500
|
||||
on_calendar: "*-*-* 09:00:00 UTC"
|
||||
persistent: true
|
||||
|
||||
# RMD filing auditor — weekly Saturday 10:00 UTC (5am CT).
|
||||
# Audits RMD filings for 2026 compliance deficiencies (PDF analysis).
|
||||
- name: pw-rmd-auditor
|
||||
description: Audit RMD filings for 2026 compliance deficiencies
|
||||
module: scripts.workers.fcc_rmd_auditor --batch --year 2026 --no-ollama
|
||||
on_calendar: "Sat *-*-* 10:00:00 UTC"
|
||||
persistent: true
|
||||
4
infra/ansible/roles/worker-crons/handlers/main.yml
Normal file
4
infra/ansible/roles/worker-crons/handlers/main.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
- name: reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
43
infra/ansible/roles/worker-crons/tasks/main.yml
Normal file
43
infra/ansible/roles/worker-crons/tasks/main.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
# worker-crons — deploy systemd .service + .timer pairs for every
|
||||
# scheduled worker job. Each entry in `worker_crons` (defaults/main.yml)
|
||||
# expands into /etc/systemd/system/{name}.{service,timer}.
|
||||
|
||||
- name: Deploy worker cron .service units
|
||||
ansible.builtin.template:
|
||||
src: cron.service.j2
|
||||
dest: "/etc/systemd/system/{{ cron.name }}.service"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop: "{{ worker_crons }}"
|
||||
loop_control:
|
||||
loop_var: cron
|
||||
notify: reload systemd
|
||||
|
||||
- name: Deploy worker cron .timer units
|
||||
ansible.builtin.template:
|
||||
src: cron.timer.j2
|
||||
dest: "/etc/systemd/system/{{ cron.name }}.timer"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop: "{{ worker_crons }}"
|
||||
loop_control:
|
||||
loop_var: cron
|
||||
notify: reload systemd
|
||||
|
||||
# Force the daemon-reload BEFORE enabling, or `systemctl enable` won't
|
||||
# see the new .timer units.
|
||||
- name: Reload systemd now (so enables below see new units)
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Enable + start worker cron timers
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ cron.name }}.timer"
|
||||
enabled: true
|
||||
state: started
|
||||
loop: "{{ worker_crons }}"
|
||||
loop_control:
|
||||
loop_var: cron
|
||||
15
infra/ansible/roles/worker-crons/templates/cron.service.j2
Normal file
15
infra/ansible/roles/worker-crons/templates/cron.service.j2
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[Unit]
|
||||
Description={{ cron.description }}
|
||||
After=performancewest.service
|
||||
Requires=performancewest.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory={{ project_dir }}
|
||||
ExecStart=/usr/bin/docker compose exec -T workers python -m {{ cron.module }}
|
||||
# Log to journald; journalctl -u {{ cron.name }}.service
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
# Prevent overlapping runs for every-minute jobs
|
||||
TimeoutStartSec=300
|
||||
11
infra/ansible/roles/worker-crons/templates/cron.timer.j2
Normal file
11
infra/ansible/roles/worker-crons/templates/cron.timer.j2
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[Unit]
|
||||
Description={{ cron.description }} (timer)
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ cron.on_calendar }}
|
||||
Persistent={{ cron.persistent | bool | ternary('true', 'false') }}
|
||||
RandomizedDelaySec=10
|
||||
Unit={{ cron.name }}.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
2
infra/ansible/roles/workers/defaults/main.yml
Normal file
2
infra/ansible/roles/workers/defaults/main.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
---
|
||||
ollama_model: "{{ vault_ollama_model | default('qwen2.5:7b') }}"
|
||||
42
infra/ansible/roles/workers/tasks/main.yml
Normal file
42
infra/ansible/roles/workers/tasks/main.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
# Performance West — Python workers role
|
||||
# Starts the document generation + formation automation workers container.
|
||||
|
||||
- name: Ensure workers container is running
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ project_dir }}"
|
||||
services:
|
||||
- workers
|
||||
- ollama
|
||||
state: present
|
||||
register: workers_compose
|
||||
|
||||
- name: Pull Ollama model (first run only)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
docker compose exec -T ollama
|
||||
ollama pull {{ ollama_model }}
|
||||
chdir: "{{ project_dir }}"
|
||||
args:
|
||||
creates: "{{ project_dir }}/ollama-model-pulled"
|
||||
register: ollama_pull
|
||||
changed_when: ollama_pull.rc == 0
|
||||
|
||||
- name: Mark Ollama model as pulled
|
||||
ansible.builtin.file:
|
||||
path: "{{ project_dir }}/ollama-model-pulled"
|
||||
state: touch
|
||||
when: ollama_pull.changed
|
||||
|
||||
- name: Verify workers job server is accepting connections
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
docker compose exec -T workers
|
||||
python -c "import urllib.request; urllib.request.urlopen('http://localhost:8090/health')"
|
||||
chdir: "{{ project_dir }}"
|
||||
register: workers_health
|
||||
retries: 10
|
||||
delay: 5
|
||||
until: workers_health.rc == 0
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
11
infra/fail2ban/filter.d/pw-api.conf
Normal file
11
infra/fail2ban/filter.d/pw-api.conf
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# fail2ban filter for Performance West API
|
||||
# Parses structured JSON access logs from the Express API.
|
||||
# Matches 403 (forbidden), 429 (rate limited), and 415 (wrong content type) responses.
|
||||
|
||||
[Definition]
|
||||
|
||||
# Match rate-limited or forbidden requests from API structured logs
|
||||
failregex = ^\[ACCESS\] .*"ip":\s*"<HOST>".*"status":\s*(403|429|415)
|
||||
^.*\[ACCESS\].*"ip":\s*"<HOST>".*"status":\s*(403|429|415)
|
||||
|
||||
ignoreregex =
|
||||
10
infra/fail2ban/jail.d/pw.conf
Normal file
10
infra/fail2ban/jail.d/pw.conf
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# fail2ban jail for Performance West API abuse protection
|
||||
|
||||
[pw-api]
|
||||
enabled = true
|
||||
filter = pw-api
|
||||
logpath = /var/log/pw-api.log
|
||||
maxretry = 20
|
||||
findtime = 300
|
||||
bantime = 3600
|
||||
action = iptables-multiport[name=pw-api, port="80,443", protocol=tcp]
|
||||
38
infra/nginx/pw-listmonk.conf
Normal file
38
infra/nginx/pw-listmonk.conf
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Listmonk — lists.performancewest.net
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name lists.performancewest.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/lists.performancewest.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/lists.performancewest.net/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
# Listmonk admin UI + API
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9100;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name lists.performancewest.net;
|
||||
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue