From 997aff6be3582f8ddb528ae8ba05e9f6b61f7d96 Mon Sep 17 00:00:00 2001 From: Jeremie Fraeys Date: Mon, 19 Jan 2026 15:02:13 -0500 Subject: [PATCH] initial infra commit --- .env.example | 17 + .gitignore | 22 + README.md | 201 +++++++++ ansible.cfg | 10 + inventory/group_vars/all.yml | 11 + playbooks/app.yml | 18 + playbooks/deploy.yml | 26 ++ playbooks/hardening.yml | 6 + playbooks/services.yml | 127 ++++++ playbooks/test_config.yml | 399 ++++++++++++++++++ roles/airflow/tasks/main.yml | 4 + roles/app_core/tasks/main.yml | 59 +++ .../app_core/templates/docker-compose.yml.j2 | 29 ++ roles/authelia/tasks/main.yml | 120 ++++++ roles/authelia/templates/configuration.yml.j2 | 72 ++++ .../authelia/templates/docker-compose.yml.j2 | 12 + roles/docker/tasks/main.yml | 174 ++++++++ roles/exporters/tasks/main.yml | 25 ++ .../exporters/templates/docker-compose.yml.j2 | 31 ++ roles/forgejo/tasks/main.yml | 55 +++ roles/forgejo/templates/docker-compose.yml.j2 | 38 ++ roles/forgejo_runner/defaults/main.yml | 5 + roles/forgejo_runner/tasks/main.yml | 93 ++++ .../templates/docker-compose.yml.j2 | 13 + roles/grafana/tasks/main.yml | 44 ++ roles/grafana/templates/datasources.yml.j2 | 15 + roles/grafana/templates/docker-compose.yml.j2 | 52 +++ roles/hardening/handlers/main.yml | 5 + roles/hardening/tasks/main.yml | 58 +++ roles/lldap/tasks/main.yml | 45 ++ roles/lldap/templates/docker-compose.yml.j2 | 23 + roles/loki/tasks/main.yml | 60 +++ roles/loki/templates/docker-compose.yml.j2 | 22 + roles/loki/templates/loki-config.yml.j2 | 31 ++ roles/prometheus/tasks/main.yml | 30 ++ .../templates/docker-compose.yml.j2 | 23 + roles/prometheus/templates/prometheus.yml.j2 | 15 + roles/spark/tasks/main.yml | 4 + roles/traefik/handlers/main.yml | 5 + roles/traefik/tasks/main.yml | 134 ++++++ .../templates/home-docker-compose.yml.j2 | 31 ++ roles/traefik/vars/main.yml | 2 + roles/watchtower/tasks/main.yml | 39 ++ .../templates/docker-compose.yml.j2 | 9 + scripts/gen-auth-secrets.sh | 37 ++ secrets/vault.example.yml | 22 + setup.sh | 134 ++++++ stackscripts/essentials.sh | 212 ++++++++++ stackscripts/services.sh | 20 + terraform/.terraform.lock.hcl | 47 +++ terraform/main.tf | 275 ++++++++++++ terraform/outputs.tf | 31 ++ terraform/variables.tf | 109 +++++ 53 files changed, 3101 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ansible.cfg create mode 100644 inventory/group_vars/all.yml create mode 100644 playbooks/app.yml create mode 100644 playbooks/deploy.yml create mode 100644 playbooks/hardening.yml create mode 100644 playbooks/services.yml create mode 100644 playbooks/test_config.yml create mode 100644 roles/airflow/tasks/main.yml create mode 100644 roles/app_core/tasks/main.yml create mode 100644 roles/app_core/templates/docker-compose.yml.j2 create mode 100644 roles/authelia/tasks/main.yml create mode 100644 roles/authelia/templates/configuration.yml.j2 create mode 100644 roles/authelia/templates/docker-compose.yml.j2 create mode 100644 roles/docker/tasks/main.yml create mode 100644 roles/exporters/tasks/main.yml create mode 100644 roles/exporters/templates/docker-compose.yml.j2 create mode 100644 roles/forgejo/tasks/main.yml create mode 100644 roles/forgejo/templates/docker-compose.yml.j2 create mode 100644 roles/forgejo_runner/defaults/main.yml create mode 100644 roles/forgejo_runner/tasks/main.yml create mode 100644 roles/forgejo_runner/templates/docker-compose.yml.j2 create mode 100644 roles/grafana/tasks/main.yml create mode 100644 roles/grafana/templates/datasources.yml.j2 create mode 100644 roles/grafana/templates/docker-compose.yml.j2 create mode 100644 roles/hardening/handlers/main.yml create mode 100644 roles/hardening/tasks/main.yml create mode 100644 roles/lldap/tasks/main.yml create mode 100644 roles/lldap/templates/docker-compose.yml.j2 create mode 100644 roles/loki/tasks/main.yml create mode 100644 roles/loki/templates/docker-compose.yml.j2 create mode 100644 roles/loki/templates/loki-config.yml.j2 create mode 100644 roles/prometheus/tasks/main.yml create mode 100644 roles/prometheus/templates/docker-compose.yml.j2 create mode 100644 roles/prometheus/templates/prometheus.yml.j2 create mode 100644 roles/spark/tasks/main.yml create mode 100644 roles/traefik/handlers/main.yml create mode 100644 roles/traefik/tasks/main.yml create mode 100644 roles/traefik/templates/home-docker-compose.yml.j2 create mode 100644 roles/traefik/vars/main.yml create mode 100644 roles/watchtower/tasks/main.yml create mode 100644 roles/watchtower/templates/docker-compose.yml.j2 create mode 100644 scripts/gen-auth-secrets.sh create mode 100644 secrets/vault.example.yml create mode 100755 setup.sh create mode 100644 stackscripts/essentials.sh create mode 100644 stackscripts/services.sh create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/main.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/variables.tf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..89f82b8 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +ANSIBLE_PRIVATE_KEY_FILE= + +TF_VAR_region=ca-central +TF_VAR_instance_type=g6-nanode-1 +TF_VAR_image=linode/debian13 +TF_VAR_ssh_port=22 +TF_VAR_timezone=America/Toronto +TF_VAR_add_cloudflare_ips=false + +TF_VAR_enable_cloudflare_dns=false +TF_VAR_enable_services_wildcard=true +TF_VAR_object_storage_bucket= +TF_VAR_object_storage_region=us-east-1 + +S3_BUCKET= +S3_REGION=us-east-1 +S3_ENDPOINT=https://us-east-1.linodeobjects.com \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f69bfa3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.terraform/ +**/.terraform/ +*.tfstate +*.tfstate.* +crash.log +terraform.tfvars +terraform/tfplan + +.env +.env.* +!.env.example + +.DS_Store +**/.DS_Store + +.vault_pass +secrets/.vault_pass +inventory/hosts.yml +inventory/host_vars/web.yml + +secrets/* +!secrets/vault.example.yml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a04736a --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# infra + +## Overview + +This repo manages two hosts: + +- `web` (`jfraeys.com`) +- `services` (`services.jfraeys.com`) + +The routing convention is `service.server.jfraeys.com`. + +Examples: + +- `grafana.jfraeys.com` -> services host +- `git.jfraeys.com` -> services host + +Traefik runs on both servers and routes only the services running on that server. + +## Quickstart + +This repo is intended to be driven by `setup.sh`: + +```bash +./setup.sh +``` + +What it does: + +- Applies Terraform from `terraform/` +- Writes `inventory/hosts.yml` and `inventory/host_vars/web.yml` (gitignored) +- Runs `playbooks/services.yml` and `playbooks/app.yml` + +If you want Terraform only: + +```bash +./setup.sh --no-ansible +``` + +## Prereqs (local) + +- `terraform` +- `ansible` +- SSH access to the hosts + +If your SSH key is passphrase-protected, you must load it into your agent before running Ansible non-interactively: + +```bash +ssh-add --apple-use-keychain ~/.ssh/id_ed25519 +``` + +## DNS (Cloudflare) + +Create A/CNAME records that point to the correct server IP. + +Recommended: + +- `jfraeys.com` -> A record to web server IPv4 +- `services.jfraeys.com` -> A record to services server IPv4 +- `grafana.jfraeys.com` -> A/CNAME to services +- `git.jfraeys.com` -> A/CNAME to services + +## TLS + +Traefik uses Let’s Encrypt via Cloudflare DNS-01. + +You must provide a Cloudflare API token in your local environment when running Ansible: + +- `CF_DNS_API_TOKEN` (preferred) +- or `TF_VAR_cloudflare_api_token` + +## SSO (Authelia OIDC) + +Authelia is exposed at: + +- `https://auth.jfraeys.com` (issuer) +- `https://auth.jfraeys.com/.well-known/openid-configuration` (discovery) + +Grafana is configured via `roles/grafana` using the Generic OAuth provider. + +Forgejo is configured via `roles/forgejo` using the Forgejo admin CLI with `--provider=openidConnect` and `--auto-discover-url`. + +Note: Forgejo pages that ask for an "OpenID URI" are legacy OpenID 2.0 and are not used for OIDC. + +## Secrets (Ansible Vault) + +Secrets are stored in `secrets/vault.yml` (encrypted). + +Create your vault from the template: + +- `secrets/vault.example.yml` -> `secrets/vault.yml` + +Run playbooks with either: + +- `--ask-vault-pass` +- or a local password file (not committed): `--vault-password-file .vault_pass` + +Notes: + +- `secrets/vault.yml` is intentionally gitignored +- `inventory/hosts.yml` and `inventory/host_vars/web.yml` are generated by `setup.sh` and intentionally gitignored + +## Playbooks + +- `playbooks/services.yml`: deploy observability + forgejo on `services` +- `playbooks/app.yml`: deploy app-side dependencies on `web` +- `playbooks/test_config.yml`: smoke test host config and deployed stacks +- `playbooks/deploy.yml`: legacy/all-in-one deploy for the services host (no tags) + +## Configuration split + +- Vault (`secrets/vault.yml`): secrets (API tokens, passwords, access keys, and sensitive Terraform `TF_VAR_*` values) +- `.env`: non-secret configuration (still treated as sensitive), such as region/instance type and non-secret endpoints + +## Linode Object Storage (demo apps) + +If you already have a Linode Object Storage bucket, demo apps can use it via the S3-compatible API. + +Recommended env vars (see `.env.example`): + +- `S3_BUCKET` +- `S3_ENDPOINT` (example: `https://us-east-1.linodeobjects.com`) +- `S3_REGION` + +Secrets (store in `secrets/vault.yml`): + +- `S3_ACCESS_KEY_ID` +- `S3_SECRET_ACCESS_KEY` + +Create a dedicated access key for demos and scope permissions as tightly as possible. + +## Grafana provisioning + +Grafana is provisioned with Prometheus and Loki datasources via the Grafana provisioning mechanism (no manual UI setup required). + +## Host vars + +Set `inventory/host_vars/web.yml`: + +- `public_ipv4`: public IPv4 of `jfraeys.com` + +This is used to allowlist Loki (`services:3100`) to only the web host. + +## Forgejo Actions runner (web host) + +A Forgejo runner is deployed on the `web` host (`roles/forgejo_runner`). + +- Requires `FORGEJO_RUNNER_REGISTRATION_TOKEN` in `secrets/vault.yml`. +- Uses a single generic `docker` label by default. +- The role auto re-registers the runner if labels change. + +To force re-register (e.g. after deleting the runner in Forgejo UI): + +```bash +ansible-playbook playbooks/app.yml \ + --vault-password-file secrets/.vault_pass \ + --limit web \ + --tags forgejo_runner \ + -e forgejo_runner_force_reregister=true +``` + +## Deploy + +Services: + +```bash +ansible-playbook playbooks/services.yml --ask-vault-pass +``` + +Web: + +```bash +ansible-playbook playbooks/app.yml --ask-vault-pass +``` + +## Terraform + +`./setup.sh` will export `TF_VAR_*` from `secrets/vault.yml` (prompting for vault password if needed) and then run Terraform with a saved plan. + +## Notes + +- Loki is exposed on `services:3100` but allowlisted in UFW to `web` only. +- Watchtower is enabled with label-based updates. +- Airflow/Spark are intentionally optional and can be enabled later via `deploy_airflow` / `deploy_spark`. + +## Role layout + +Services host (`services`): + +- `roles/traefik` +- `roles/exporters` (node-exporter + cAdvisor) +- `roles/prometheus` +- `roles/loki` +- `roles/grafana` +- `roles/forgejo` +- `roles/watchtower` + +Web host (`web`): + +- `roles/traefik` +- `roles/app_core` (optional shared Postgres/Redis) +- `roles/forgejo_runner` diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..8924d2b --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,10 @@ +[defaults] +inventory = inventory/ +remote_user=ansible +host_key_checking=True +roles_path=roles +interpreter_python=/usr/bin/python3 + +[ssh_connection] +ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o ControlPath=~/.ansible/cp/ansible-ssh-%%h-%%p-%%r -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes + diff --git a/inventory/group_vars/all.yml b/inventory/group_vars/all.yml new file mode 100644 index 0000000..e806854 --- /dev/null +++ b/inventory/group_vars/all.yml @@ -0,0 +1,11 @@ +traefik_acme_email: "admin@jfraeys.com" +traefik_certresolver: "cloudflare" + +ansible_port: "{{ lookup('env', 'TF_VAR_ssh_port') | default(22, true) }}" +ansible_ssh_private_key_file: "{{ lookup('env', 'ANSIBLE_PRIVATE_KEY_FILE') | default('~/.ssh/id_ed25519', true) }}" + +grafana_hostname: "grafana.jfraeys.com" +forgejo_hostname: "git.jfraeys.com" + +auth_hostname: "auth.jfraeys.com" +lldap_base_dn: "dc=jfraeys,dc=com" diff --git a/playbooks/app.yml b/playbooks/app.yml new file mode 100644 index 0000000..fba96c1 --- /dev/null +++ b/playbooks/app.yml @@ -0,0 +1,18 @@ +--- +- hosts: web_hosts + become: true + pre_tasks: + - name: Load vault vars if present + include_vars: + file: "{{ playbook_dir }}/../secrets/vault.yml" + when: (lookup('ansible.builtin.fileglob', playbook_dir ~ '/../secrets/vault.yml', wantlist=True) | length) > 0 + tags: always + roles: + - role: docker + tags: [docker] + - role: traefik + tags: [traefik] + - role: app_core + tags: [app_core] + - role: forgejo_runner + tags: [forgejo_runner] diff --git a/playbooks/deploy.yml b/playbooks/deploy.yml new file mode 100644 index 0000000..97806fb --- /dev/null +++ b/playbooks/deploy.yml @@ -0,0 +1,26 @@ +--- +- name: Deploy all services + hosts: services_hosts + become: true + + pre_tasks: + - name: Load vault vars if present + include_vars: + file: ../secrets/vault.yml + ignore_errors: true + + roles: + - docker + - traefik + - lldap + - authelia + - exporters + - prometheus + - loki + - grafana + - forgejo + - watchtower + - role: airflow + when: deploy_airflow | default(false) + - role: spark + when: deploy_spark | default(false) diff --git a/playbooks/hardening.yml b/playbooks/hardening.yml new file mode 100644 index 0000000..fc20035 --- /dev/null +++ b/playbooks/hardening.yml @@ -0,0 +1,6 @@ +--- +- name: Hardening + hosts: all + become: true + roles: + - hardening diff --git a/playbooks/services.yml b/playbooks/services.yml new file mode 100644 index 0000000..d00502e --- /dev/null +++ b/playbooks/services.yml @@ -0,0 +1,127 @@ +--- +- hosts: services_hosts + become: true + pre_tasks: + - name: Load vault vars if present + include_vars: + file: "{{ playbook_dir }}/../secrets/vault.yml" + when: (lookup('ansible.builtin.fileglob', playbook_dir ~ '/../secrets/vault.yml', wantlist=True) | length) > 0 + tags: always + roles: + - role: docker + tags: [docker] + - role: traefik + tags: [traefik] + - role: lldap + tags: [lldap] + - role: authelia + tags: [authelia] + - role: exporters + tags: [exporters] + - role: prometheus + tags: [prometheus] + - role: loki + tags: [loki] + - role: grafana + tags: [grafana] + - role: forgejo + tags: [forgejo] + - role: watchtower + tags: [watchtower] + + post_tasks: + - name: Read Grafana Traefik router rule label + shell: | + set -euo pipefail + id=$(docker compose ps -q grafana) + docker inspect ${id} | python3 -c 'import json,sys; d=json.load(sys.stdin)[0]; print(d.get("Config",{}).get("Labels",{}).get("traefik.http.routers.grafana.rule",""))' + args: + chdir: /opt/grafana + register: grafana_router_rule + changed_when: false + tags: [grafana] + + - name: Fail if Grafana Traefik router rule label is not configured as expected + assert: + that: + - grafana_router_rule.stdout == ("Host(`" ~ grafana_hostname ~ "`)") + fail_msg: "Grafana Traefik router rule label mismatch. expected=Host(`{{ grafana_hostname }}`) got={{ grafana_router_rule.stdout | default('') }}. If you used --start-at-task, rerun the play without it so docker compose can recreate the container with updated labels." + tags: [grafana] + + - name: Trigger Traefik certificate request for Grafana hostname + command: curl -k -s -o /dev/null -w "%{http_code}" --resolve "{{ grafana_hostname }}:443:127.0.0.1" "https://{{ grafana_hostname }}/" + register: grafana_tls_warmup + changed_when: false + retries: 30 + delay: 2 + until: grafana_tls_warmup.stdout != '000' + tags: [grafana] + + - name: Wait for Traefik certificate SAN to include Grafana hostname + shell: | + set -euo pipefail + echo | openssl s_client -servername "{{ grafana_hostname }}" -connect 127.0.0.1:443 2>/dev/null | openssl x509 -noout -text | grep -q "DNS:{{ grafana_hostname }}" + register: grafana_origin_tls + changed_when: false + retries: 90 + delay: 5 + until: grafana_origin_tls.rc == 0 + tags: [grafana] + + - name: Trigger Traefik certificate request for Forgejo hostname + command: curl -k -s -o /dev/null -w "%{http_code}" --resolve "{{ forgejo_hostname }}:443:127.0.0.1" "https://{{ forgejo_hostname }}/" + register: forgejo_tls_warmup + changed_when: false + retries: 30 + delay: 2 + until: forgejo_tls_warmup.stdout != '000' + tags: [forgejo] + + - name: Read Forgejo Traefik router rule label + shell: | + set -euo pipefail + id=$(docker compose ps -q forgejo) + docker inspect ${id} | python3 -c 'import json,sys; d=json.load(sys.stdin)[0]; print(d.get("Config",{}).get("Labels",{}).get("traefik.http.routers.forgejo.rule",""))' + args: + chdir: /opt/forgejo + register: forgejo_router_rule + changed_when: false + tags: [forgejo] + + - name: Fail if Forgejo Traefik router rule label is not configured as expected + assert: + that: + - forgejo_router_rule.stdout == ("Host(`" ~ forgejo_hostname ~ "`)") + fail_msg: "Forgejo Traefik router rule label mismatch. expected=Host(`{{ forgejo_hostname }}`) got={{ forgejo_router_rule.stdout | default('') }}. If you used --start-at-task, rerun the play without it so docker compose can recreate the container with updated labels." + tags: [forgejo] + + - name: Wait for Traefik certificate SAN to include Forgejo hostname + shell: | + set -euo pipefail + echo | openssl s_client -servername "{{ forgejo_hostname }}" -connect 127.0.0.1:443 2>/dev/null | openssl x509 -noout -text | grep -q "DNS:{{ forgejo_hostname }}" + register: forgejo_origin_tls + changed_when: false + retries: 90 + delay: 5 + until: forgejo_origin_tls.rc == 0 + tags: [forgejo] + + - name: Trigger Traefik certificate request for Authelia hostname + command: curl -k -s -o /dev/null -w "%{http_code}" --resolve "{{ auth_hostname }}:443:127.0.0.1" "https://{{ auth_hostname }}/" + register: authelia_tls_warmup + changed_when: false + retries: 30 + delay: 2 + until: authelia_tls_warmup.stdout != '000' + tags: [authelia] + + - name: Wait for Traefik certificate SAN to include Authelia hostname + shell: | + set -euo pipefail + echo | openssl s_client -servername "{{ auth_hostname }}" -connect 127.0.0.1:443 2>/dev/null | openssl x509 -noout -text | grep -q "DNS:{{ auth_hostname }}" + register: authelia_origin_tls + changed_when: false + retries: 90 + delay: 5 + until: authelia_origin_tls.rc == 0 + tags: [authelia] diff --git a/playbooks/test_config.yml b/playbooks/test_config.yml new file mode 100644 index 0000000..b619392 --- /dev/null +++ b/playbooks/test_config.yml @@ -0,0 +1,399 @@ +--- +- name: Test Deployment Configuration + hosts: all + become: true + tasks: + - name: Load vault vars if present + include_vars: + file: "{{ playbook_dir }}/../secrets/vault.yml" + no_log: true + when: (lookup('ansible.builtin.fileglob', playbook_dir ~ '/../secrets/vault.yml', wantlist=True) | length) > 0 + + - name: Check SSH service status + command: systemctl is-active sshd + register: ssh_status + changed_when: false + - debug: + msg: "SSH service is {{ ssh_status.stdout | default('') }}" + - name: Check SSH Port Configuration + command: sshd -T + register: ssh_port + changed_when: false + failed_when: false + - debug: + msg: "SSH port configured as {{ (ssh_port.stdout | default('') | regex_search('(?m)^port\\s+([0-9]+)$', '\\1')) | default('Unknown') }}" + - name: Check Docker version + command: docker --version + register: docker_version + changed_when: false + - debug: + msg: "Docker Version: {{ docker_version.stdout }}" + - name: Check Docker Compose version (hyphen) + command: docker-compose --version + register: docker_compose_version_hyphen + failed_when: false + changed_when: false + - name: Check Docker Compose version (docker compose) + command: docker compose version + register: docker_compose_version_space + failed_when: false + changed_when: false + - name: Display Docker Compose version + debug: + msg: > + {% if docker_compose_version_hyphen.stdout %} + + + Docker Compose version (docker-compose): {{ docker_compose_version_hyphen.stdout }} + {% elif docker_compose_version_space.stdout %} + + + Docker Compose version (docker compose): {{ docker_compose_version_space.stdout }} + {% else %} + + + Docker Compose not found + {% endif %} + + - name: Check Ansible version + command: ansible --version + register: ansible_version + changed_when: false + failed_when: false + - debug: + msg: "Ansible Version: {{ (ansible_version.stdout | default('')) .split('\n')[0] if (ansible_version.stdout | default('') | length) > 0 else 'Not installed' }}" + - name: Check UFW status + command: ufw status verbose + register: ufw_status + changed_when: false + - debug: + msg: "UFW Status: {{ ufw_status.stdout }}" + - name: Check Fail2ban service status + command: systemctl is-active fail2ban + register: fail2ban_status + changed_when: false + failed_when: false + - debug: + msg: "Fail2ban is {{ fail2ban_status.stdout }}" + - name: Display logrotate custom config + command: cat /etc/logrotate.d/custom + register: logrotate_config + changed_when: false + failed_when: false + - debug: + msg: "Logrotate custom config:\n{{ logrotate_config.stdout | default('No custom logrotate config found') }}" + - name: Check running Docker containers + command: docker ps + register: docker_ps + changed_when: false + - debug: + msg: "Docker containers:\n{{ docker_ps.stdout }}" + + - name: Determine host role + set_fact: + is_services_host: "{{ 'services_hosts' in group_names }}" + is_web_host: "{{ 'web_hosts' in group_names }}" + + - name: Define expected stacks for services host + set_fact: + expected_stacks: + - { name: traefik, dir: /opt/traefik } + - { name: lldap, dir: /opt/lldap } + - { name: authelia, dir: /opt/authelia } + - { name: exporters, dir: /opt/exporters } + - { name: prometheus, dir: /opt/prometheus } + - { name: loki, dir: /opt/loki } + - { name: grafana, dir: /opt/grafana } + - { name: forgejo, dir: /opt/forgejo } + - { name: watchtower, dir: /opt/watchtower } + when: is_services_host + + - name: Define expected stacks for web host + set_fact: + expected_stacks: + - { name: traefik, dir: /opt/traefik } + - { name: app_core, dir: /opt/app } + when: is_web_host + + - name: Check that expected compose directories exist + stat: + path: "{{ item.dir }}/docker-compose.yml" + register: compose_files + loop: "{{ expected_stacks | default([]) }}" + changed_when: false + + - name: Fail if any compose file is missing + assert: + that: + - item.stat.exists + fail_msg: "Missing docker-compose.yml for {{ item.item.name }} at {{ item.item.dir }}/docker-compose.yml" + loop: "{{ compose_files.results | default([]) }}" + when: expected_stacks is defined + + - name: Read expected services per stack + command: docker compose config --services + args: + chdir: "{{ item.dir }}" + register: stack_expected + loop: "{{ expected_stacks | default([]) }}" + changed_when: false + + - name: Read service status/health per stack (docker inspect) + shell: | + set -euo pipefail + ids=$(docker compose ps -q) + if [ -z "${ids}" ]; then + exit 0 + fi + {% raw %}docker inspect --format '{{ index .Config.Labels "com.docker.compose.service" }} {{ .State.Status }} {{ if .State.Health }}{{ .State.Health.Status }}{{ else }}none{{ end }}' ${ids}{% endraw %} + args: + chdir: "{{ item.dir }}" + register: stack_status + loop: "{{ expected_stacks | default([]) }}" + changed_when: false + failed_when: false + + - name: Assert all services in each stack are running (and healthy if healthcheck exists) + assert: + that: + - (expected | difference(running_services)) | length == 0 + - bad_health_services | length == 0 + fail_msg: >- + Stack {{ stack.name }} service status unhealthy. + Missing running={{ expected | difference(running_services) }}. + Bad health={{ bad_health_services }}. + Expected={{ expected }} + Inspect={{ status_lines }} + loop: "{{ (expected_stacks | default([])) | zip(stack_expected.results, stack_status.results) | list }}" + vars: + stack: "{{ item.0 }}" + expected: "{{ item.1.stdout_lines | default([]) }}" + status_lines: "{{ item.2.stdout_lines | default([]) }}" + running_services: >- + {{ status_lines + | map('regex_findall', '^(\S+)\s+running\s+') + | select('truthy') + | map('first') + | list }} + ok_services: >- + {{ status_lines + | map('regex_findall', '^(\S+)\s+running\s+(?:healthy|none)\s*$') + | select('truthy') + | map('first') + | list }} + bad_health_services: >- + {{ (running_services | default([])) | difference(ok_services | default([])) }} + when: expected_stacks is defined + + - name: Ensure proxy network exists + command: docker network inspect proxy + register: proxy_network + changed_when: false + + - name: Ensure monitoring network exists on services host + command: docker network inspect monitoring + register: monitoring_network + changed_when: false + when: is_services_host + + - name: Check Prometheus readiness on services host + command: docker compose exec -T prometheus wget -qO- http://127.0.0.1:9090/-/ready + args: + chdir: /opt/prometheus + register: prometheus_ready + changed_when: false + when: is_services_host + + - name: Fail if Prometheus is not ready + assert: + that: + - prometheus_ready.stdout | default('') in ['Prometheus is Ready.', 'Prometheus Server is Ready.'] + fail_msg: "Prometheus readiness check failed. Output={{ prometheus_ready.stdout | default('') }}" + when: is_services_host + + - name: Check Grafana health on services host + command: docker compose exec -T grafana wget -qO- http://127.0.0.1:3000/api/health + args: + chdir: /opt/grafana + register: grafana_health + changed_when: false + failed_when: false + when: is_services_host + + - name: Fail if Grafana health endpoint is not reachable + assert: + that: + - grafana_health.rc == 0 + fail_msg: "Grafana health endpoint check failed (inside container). rc={{ grafana_health.rc }} output={{ grafana_health.stdout | default('') }}" + when: is_services_host + + - name: Check Loki readiness on services host + uri: + url: http://127.0.0.1:3100/ready + method: GET + status_code: [200, 503] + register: loki_ready + until: loki_ready.status == 200 + retries: 30 + delay: 2 + changed_when: false + when: is_services_host + + - name: Check Traefik dynamic config contains Grafana router rule + shell: | + set -euo pipefail + grep -Fq 'Host(`{{ grafana_hostname }}`)' /opt/traefik/dynamic/base.yml + register: grafana_router_rule + changed_when: false + failed_when: false + when: is_services_host + + - name: Fail if Grafana Traefik router rule is not configured as expected + assert: + that: + - grafana_router_rule.rc == 0 + fail_msg: "Grafana Traefik router rule mismatch in /opt/traefik/dynamic/base.yml. expected=Host(`{{ grafana_hostname }}`)" + when: is_services_host + + - name: Check Traefik dynamic config contains Forgejo router rule + shell: | + set -euo pipefail + grep -Fq 'Host(`{{ forgejo_hostname }}`)' /opt/traefik/dynamic/base.yml + register: forgejo_router_rule + changed_when: false + failed_when: false + when: is_services_host + + - name: Fail if Forgejo Traefik router rule is not configured as expected + assert: + that: + - forgejo_router_rule.rc == 0 + fail_msg: "Forgejo Traefik router rule mismatch in /opt/traefik/dynamic/base.yml. expected=Host(`{{ forgejo_hostname }}`)" + when: is_services_host + + - name: Check Traefik dynamic config contains Authelia router rule + shell: | + set -euo pipefail + grep -Fq 'Host(`{{ auth_hostname }}`)' /opt/traefik/dynamic/base.yml + register: authelia_router_rule + changed_when: false + failed_when: false + when: is_services_host + + - name: Fail if Authelia Traefik router rule is not configured as expected + assert: + that: + - authelia_router_rule.rc == 0 + fail_msg: "Authelia Traefik router rule mismatch in /opt/traefik/dynamic/base.yml. expected=Host(`{{ auth_hostname }}`)" + when: is_services_host + + - name: Check Traefik serves a valid TLS certificate for Grafana hostname (origin) + shell: | + set -euo pipefail + echo | openssl s_client -servername "{{ grafana_hostname }}" -connect 127.0.0.1:443 2>/dev/null | grep -q "Verify return code: 0 (ok)" + register: grafana_origin_tls + changed_when: false + retries: 30 + delay: 2 + until: grafana_origin_tls.rc == 0 + when: is_services_host + + - name: Check Traefik serves a valid TLS certificate for Forgejo hostname (origin) + shell: | + set -euo pipefail + echo | openssl s_client -servername "{{ forgejo_hostname }}" -connect 127.0.0.1:443 2>/dev/null | grep -q "Verify return code: 0 (ok)" + register: forgejo_origin_tls + changed_when: false + retries: 30 + delay: 2 + until: forgejo_origin_tls.rc == 0 + when: is_services_host + + - name: Check Traefik serves a valid TLS certificate for Authelia hostname (origin) + shell: | + set -euo pipefail + echo | openssl s_client -servername "{{ auth_hostname }}" -connect 127.0.0.1:443 2>/dev/null | grep -q "Verify return code: 0 (ok)" + register: authelia_origin_tls + changed_when: false + retries: 30 + delay: 2 + until: authelia_origin_tls.rc == 0 + when: is_services_host + + - name: Check Authelia OIDC discovery issuer (origin) + shell: | + set -euo pipefail + curl -k -sS --resolve "{{ auth_hostname }}:443:127.0.0.1" "https://{{ auth_hostname }}/.well-known/openid-configuration" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("issuer",""))' + register: authelia_oidc_issuer + changed_when: false + retries: 30 + delay: 2 + until: authelia_oidc_issuer.stdout | default('') | length > 0 + when: is_services_host + + - name: Fail if Authelia OIDC discovery issuer is not configured as expected + assert: + that: + - authelia_oidc_issuer.stdout == ("https://" ~ auth_hostname) + fail_msg: "Authelia OIDC issuer mismatch. expected=https://{{ auth_hostname }} got={{ authelia_oidc_issuer.stdout | default('') }}" + when: is_services_host + + - name: Check LLDAP web UI is reachable on services host + uri: + url: http://127.0.0.1:17170/ + method: GET + status_code: [200, 302] + register: lldap_web + changed_when: false + when: is_services_host + + - name: Read object storage configuration from controller environment + set_fact: + s3_bucket: "{{ lookup('env', 'S3_BUCKET') | default('', true) }}" + s3_region: "{{ lookup('env', 'S3_REGION') | default(lookup('env', 'TF_VAR_object_storage_region'), true) | default('us-east-1', true) }}" + changed_when: false + + - name: Compute object storage endpoint from controller environment + set_fact: + s3_endpoint: "{{ lookup('env', 'S3_ENDPOINT') | default('https://' ~ s3_region ~ '.linodeobjects.com', true) }}" + changed_when: false + + - name: Smoke test Linode Object Storage credentials (head-bucket) + command: >- + docker run --rm + -e AWS_ACCESS_KEY_ID + -e AWS_SECRET_ACCESS_KEY + -e AWS_DEFAULT_REGION + -e AWS_EC2_METADATA_DISABLED=true + amazon/aws-cli:2.15.57 + s3api head-bucket --bucket {{ s3_bucket | quote }} --endpoint-url {{ s3_endpoint | quote }} + environment: + AWS_ACCESS_KEY_ID: "{{ S3_ACCESS_KEY_ID | default('') }}" + AWS_SECRET_ACCESS_KEY: "{{ S3_SECRET_ACCESS_KEY | default('') }}" + AWS_DEFAULT_REGION: "{{ s3_region }}" + register: s3_head_bucket + changed_when: false + no_log: true + when: + - (s3_bucket | default('') | length) > 0 + - (S3_ACCESS_KEY_ID | default('') | length) > 0 + - (S3_SECRET_ACCESS_KEY | default('') | length) > 0 + + - name: Fail if object storage smoke test failed + assert: + that: + - s3_head_bucket.rc == 0 + fail_msg: "Object storage smoke test failed (head-bucket). Check S3_BUCKET/S3_REGION/S3_ENDPOINT and S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY in vault." + when: + - (s3_bucket | default('') | length) > 0 + - (S3_ACCESS_KEY_ID | default('') | length) > 0 + - (S3_SECRET_ACCESS_KEY | default('') | length) > 0 + + - name: Check Loki is reachable from web host (allowlist) + uri: + url: "http://{{ hostvars['services'].ansible_host }}:3100/ready" + method: GET + status_code: 200 + register: loki_from_web_ready + when: is_web_host diff --git a/roles/airflow/tasks/main.yml b/roles/airflow/tasks/main.yml new file mode 100644 index 0000000..31c421f --- /dev/null +++ b/roles/airflow/tasks/main.yml @@ -0,0 +1,4 @@ +--- +- name: Airflow role placeholder + debug: + msg: "Airflow role is not implemented yet (deploy_airflow is optional)." diff --git a/roles/app_core/tasks/main.yml b/roles/app_core/tasks/main.yml new file mode 100644 index 0000000..520c475 --- /dev/null +++ b/roles/app_core/tasks/main.yml @@ -0,0 +1,59 @@ +--- +- name: Read Postgres password + set_fact: + app_core_postgres_password: "{{ POSTGRES_PASSWORD | default(lookup('env', 'POSTGRES_PASSWORD')) }}" + +- name: Read S3 configuration (optional) + set_fact: + app_core_s3_bucket: "{{ S3_BUCKET | default(lookup('env', 'S3_BUCKET')) | default('') }}" + app_core_s3_region: "{{ S3_REGION | default(lookup('env', 'S3_REGION')) | default('us-east-1') }}" + app_core_s3_endpoint: "{{ S3_ENDPOINT | default(lookup('env', 'S3_ENDPOINT')) | default('') }}" + app_core_s3_access_key_id: "{{ S3_ACCESS_KEY_ID | default(lookup('env', 'S3_ACCESS_KEY_ID')) | default('') }}" + app_core_s3_secret_access_key: "{{ S3_SECRET_ACCESS_KEY | default(lookup('env', 'S3_SECRET_ACCESS_KEY')) | default('') }}" + no_log: true + +- name: Fail if Postgres password is missing + fail: + msg: "POSTGRES_PASSWORD is required" + when: app_core_postgres_password | length == 0 + +- name: Create app directory + file: + path: /opt/app + state: directory + +- name: Write app environment file (optional) + copy: + dest: /opt/app/app.env + mode: '0600' + content: | + S3_BUCKET={{ app_core_s3_bucket }} + S3_REGION={{ app_core_s3_region }} + S3_ENDPOINT={{ app_core_s3_endpoint | default('https://' ~ app_core_s3_region ~ '.linodeobjects.com') }} + S3_ACCESS_KEY_ID={{ app_core_s3_access_key_id }} + S3_SECRET_ACCESS_KEY={{ app_core_s3_secret_access_key }} + when: + - (app_core_s3_bucket | length) > 0 + - (app_core_s3_access_key_id | length) > 0 + - (app_core_s3_secret_access_key | length) > 0 + no_log: true + +- name: Copy Docker Compose file for app + template: + src: docker-compose.yml.j2 + dest: /opt/app/docker-compose.yml + +- name: Ensure app network exists + command: docker network inspect app + register: app_network + changed_when: false + failed_when: false + +- name: Create app network if missing + command: docker network create app + when: app_network.rc != 0 + +- name: Deploy app stack + command: docker compose up -d + args: + chdir: /opt/app diff --git a/roles/app_core/templates/docker-compose.yml.j2 b/roles/app_core/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..c00168e --- /dev/null +++ b/roles/app_core/templates/docker-compose.yml.j2 @@ -0,0 +1,29 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_PASSWORD: "{{ app_core_postgres_password }}" + POSTGRES_USER: "app" + POSTGRES_DB: "app" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - app + restart: unless-stopped + + redis: + image: redis:7 + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + networks: + - app + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + +networks: + app: + external: true diff --git a/roles/authelia/tasks/main.yml b/roles/authelia/tasks/main.yml new file mode 100644 index 0000000..73775e8 --- /dev/null +++ b/roles/authelia/tasks/main.yml @@ -0,0 +1,120 @@ +--- +- name: Read LLDAP admin password (for Authelia LDAP bind) + set_fact: + lldap_admin_password: "{{ LLDAP_ADMIN_PASSWORD | default(lookup('env', 'LLDAP_ADMIN_PASSWORD')) }}" + no_log: true + +- name: Fail if LLDAP admin password is missing + fail: + msg: "LLDAP_ADMIN_PASSWORD is required" + when: lldap_admin_password | length == 0 + +- name: Read Authelia identity validation reset password JWT secret + set_fact: + authelia_reset_password_jwt_secret: "{{ AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET | default(lookup('env', 'AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET')) }}" + no_log: true + +- name: Fail if Authelia identity validation reset password JWT secret is missing + fail: + msg: "AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET is required" + when: authelia_reset_password_jwt_secret | length == 0 + +- name: Read Authelia session secret + set_fact: + authelia_session_secret: "{{ AUTHELIA_SESSION_SECRET | default(lookup('env', 'AUTHELIA_SESSION_SECRET')) }}" + no_log: true + +- name: Fail if Authelia session secret is missing + fail: + msg: "AUTHELIA_SESSION_SECRET is required" + when: authelia_session_secret | length == 0 + +- name: Read Authelia storage encryption key + set_fact: + authelia_storage_encryption_key: "{{ AUTHELIA_STORAGE_ENCRYPTION_KEY | default(lookup('env', 'AUTHELIA_STORAGE_ENCRYPTION_KEY')) }}" + no_log: true + +- name: Fail if Authelia storage encryption key is missing + fail: + msg: "AUTHELIA_STORAGE_ENCRYPTION_KEY is required" + when: authelia_storage_encryption_key | length == 0 + +- name: Read Authelia OIDC HMAC secret + set_fact: + authelia_oidc_hmac_secret: "{{ AUTHELIA_OIDC_HMAC_SECRET | default(lookup('env', 'AUTHELIA_OIDC_HMAC_SECRET')) }}" + no_log: true + +- name: Fail if Authelia OIDC HMAC secret is missing + fail: + msg: "AUTHELIA_OIDC_HMAC_SECRET is required" + when: authelia_oidc_hmac_secret | length == 0 + +- name: Read Authelia OIDC private key + set_fact: + authelia_oidc_private_key_pem: "{{ AUTHELIA_OIDC_PRIVATE_KEY_PEM | default(lookup('env', 'AUTHELIA_OIDC_PRIVATE_KEY_PEM')) }}" + no_log: true + +- name: Fail if Authelia OIDC private key is missing + fail: + msg: "AUTHELIA_OIDC_PRIVATE_KEY_PEM is required" + when: authelia_oidc_private_key_pem | length == 0 + +- name: Read OIDC client secret for Grafana + set_fact: + authelia_oidc_grafana_client_secret_plain: "{{ AUTHELIA_OIDC_GRAFANA_CLIENT_SECRET | default(lookup('env', 'AUTHELIA_OIDC_GRAFANA_CLIENT_SECRET')) }}" + no_log: true + +- name: Fail if OIDC client secret for Grafana is missing + fail: + msg: "AUTHELIA_OIDC_GRAFANA_CLIENT_SECRET is required" + when: authelia_oidc_grafana_client_secret_plain | length == 0 + +- name: Read OIDC client secret for Forgejo + set_fact: + authelia_oidc_forgejo_client_secret_plain: "{{ AUTHELIA_OIDC_FORGEJO_CLIENT_SECRET | default(lookup('env', 'AUTHELIA_OIDC_FORGEJO_CLIENT_SECRET')) }}" + no_log: true + +- name: Fail if OIDC client secret for Forgejo is missing + fail: + msg: "AUTHELIA_OIDC_FORGEJO_CLIENT_SECRET is required" + when: authelia_oidc_forgejo_client_secret_plain | length == 0 + +- name: Generate OIDC client secret digest for Grafana + command: >- + docker run --rm authelia/authelia:latest authelia crypto hash generate pbkdf2 --variant sha512 --iterations 310000 --password {{ authelia_oidc_grafana_client_secret_plain | quote }} + register: authelia_oidc_grafana_client_secret_hash_cmd + changed_when: false + no_log: true + +- name: Generate OIDC client secret digest for Forgejo + command: >- + docker run --rm authelia/authelia:latest authelia crypto hash generate pbkdf2 --variant sha512 --iterations 310000 --password {{ authelia_oidc_forgejo_client_secret_plain | quote }} + register: authelia_oidc_forgejo_client_secret_hash_cmd + changed_when: false + no_log: true + +- name: Set OIDC client secret digests + set_fact: + authelia_oidc_grafana_client_secret_hash: "{{ authelia_oidc_grafana_client_secret_hash_cmd.stdout | trim | regex_replace('^Digest:\\s*', '') }}" + authelia_oidc_forgejo_client_secret_hash: "{{ authelia_oidc_forgejo_client_secret_hash_cmd.stdout | trim | regex_replace('^Digest:\\s*', '') }}" + no_log: true + +- name: Create Authelia directory + file: + path: /opt/authelia + state: directory + +- name: Copy Authelia configuration + template: + src: configuration.yml.j2 + dest: /opt/authelia/configuration.yml + +- name: Copy Docker Compose file for Authelia + template: + src: docker-compose.yml.j2 + dest: /opt/authelia/docker-compose.yml + +- name: Deploy Authelia + command: docker compose up -d --force-recreate + args: + chdir: /opt/authelia diff --git a/roles/authelia/templates/configuration.yml.j2 b/roles/authelia/templates/configuration.yml.j2 new file mode 100644 index 0000000..2f2be86 --- /dev/null +++ b/roles/authelia/templates/configuration.yml.j2 @@ -0,0 +1,72 @@ +server: + address: 'tcp://:9091/' + +log: + level: 'info' + +identity_validation: + reset_password: + jwt_secret: "{{ authelia_reset_password_jwt_secret }}" + +session: + secret: "{{ authelia_session_secret }}" + cookies: + - domain: 'jfraeys.com' + authelia_url: 'https://{{ auth_hostname }}' + +storage: + encryption_key: "{{ authelia_storage_encryption_key }}" + local: + path: '/config/db.sqlite3' + +notifier: + filesystem: + filename: '/config/notification.txt' + +authentication_backend: + ldap: + implementation: 'lldap' + address: 'ldap://lldap:3890' + base_dn: '{{ lldap_base_dn }}' + user: 'cn=admin,ou=people,{{ lldap_base_dn }}' + password: "{{ lldap_admin_password }}" + +access_control: + default_policy: 'one_factor' + +identity_providers: + oidc: + hmac_secret: "{{ authelia_oidc_hmac_secret }}" + jwks: + - algorithm: 'RS256' + use: 'sig' + key: | +{% for line in authelia_oidc_private_key_pem.splitlines() %} + {{ line }} +{% endfor %} + clients: + - client_id: 'grafana' + client_name: 'Grafana' + client_secret: "{{ authelia_oidc_grafana_client_secret_hash }}" + redirect_uris: + - 'https://{{ grafana_hostname }}/login/generic_oauth' + scopes: + - 'openid' + - 'profile' + - 'email' + - 'groups' + authorization_policy: 'one_factor' + require_pkce: true + + - client_id: 'forgejo' + client_name: 'Forgejo' + client_secret: "{{ authelia_oidc_forgejo_client_secret_hash }}" + redirect_uris: + - 'https://{{ forgejo_hostname }}/user/oauth2/authelia/callback' + scopes: + - 'openid' + - 'email' + - 'profile' + - 'groups' + authorization_policy: 'one_factor' + require_pkce: true diff --git a/roles/authelia/templates/docker-compose.yml.j2 b/roles/authelia/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..b0c1f43 --- /dev/null +++ b/roles/authelia/templates/docker-compose.yml.j2 @@ -0,0 +1,12 @@ +services: + authelia: + image: authelia/authelia:latest + volumes: + - /opt/authelia:/config + networks: + - proxy + restart: unless-stopped + +networks: + proxy: + external: true diff --git a/roles/docker/tasks/main.yml b/roles/docker/tasks/main.yml new file mode 100644 index 0000000..863141f --- /dev/null +++ b/roles/docker/tasks/main.yml @@ -0,0 +1,174 @@ +--- +- name: Check if Docker is installed + command: docker --version + register: docker_installed + changed_when: false + failed_when: false + +- name: Check if Docker Compose (v2) is installed + command: docker compose version + register: docker_compose_installed + changed_when: false + failed_when: false + when: ansible_facts['os_family'] == "Debian" + +- name: Install Docker APT repo dependencies + apt: + name: + - ca-certificates + - curl + - gnupg + state: present + update_cache: true + when: ansible_facts['os_family'] == "Debian" and (docker_installed.rc != 0 or (docker_compose_installed is defined and docker_compose_installed.rc != 0)) + +- name: Determine Docker repository codename and architecture + set_fact: + docker_repo_codename: "{{ 'bookworm' if ansible_facts['distribution_release'] in ['trixie'] else ansible_facts['distribution_release'] }}" + docker_repo_arch: "{{ 'amd64' if ansible_facts['architecture'] == 'x86_64' else ('arm64' if ansible_facts['architecture'] in ['aarch64', 'arm64'] else ansible_facts['architecture']) }}" + when: ansible_facts['os_family'] == "Debian" and (docker_installed.rc != 0 or (docker_compose_installed is defined and docker_compose_installed.rc != 0)) + +- name: Ensure Docker apt keyrings directory exists + file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + when: ansible_facts['os_family'] == "Debian" and (docker_installed.rc != 0 or (docker_compose_installed is defined and docker_compose_installed.rc != 0)) + +- name: Install Docker GPG key + get_url: + url: https://download.docker.com/linux/debian/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + when: ansible_facts['os_family'] == "Debian" and (docker_installed.rc != 0 or (docker_compose_installed is defined and docker_compose_installed.rc != 0)) + +- name: Add Docker apt repository + apt_repository: + repo: "deb [arch={{ docker_repo_arch }} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian {{ docker_repo_codename }} stable" + state: present + filename: docker + when: ansible_facts['os_family'] == "Debian" and (docker_installed.rc != 0 or (docker_compose_installed is defined and docker_compose_installed.rc != 0)) + +- name: Install Docker on Linux (Debian) + apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + update_cache: true + register: docker_ce_install + ignore_errors: true + when: ansible_facts['os_family'] == "Debian" and (docker_installed.rc != 0 or (docker_compose_installed is defined and docker_compose_installed.rc != 0)) + +- name: Fallback - install Docker from Debian repos if docker-ce is unavailable + apt: + name: + - docker.io + state: present + update_cache: true + when: ansible_facts['os_family'] == "Debian" and (docker_ce_install is defined and docker_ce_install is failed) + +- name: Ensure Docker CLI plugins directory exists + file: + path: /usr/local/lib/docker/cli-plugins + state: directory + mode: "0755" + when: ansible_facts['os_family'] == "Debian" and (docker_ce_install is defined and docker_ce_install is failed) + +- name: Fallback - install Docker Compose v2 plugin binary + get_url: + url: "https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-{{ 'x86_64' if ansible_facts['architecture'] == 'x86_64' else 'aarch64' }}" + dest: /usr/local/lib/docker/cli-plugins/docker-compose + mode: "0755" + when: ansible_facts['os_family'] == "Debian" and (docker_ce_install is defined and docker_ce_install is failed) + +- name: Check if Docker Desktop is running on macOS + command: > + osascript -e 'tell application "Docker" to get the running' + register: docker_desktop_running + ignore_errors: true + when: ansible_facts['os_family'] == "Darwin" + +- name: Notify if Docker Desktop is not running + debug: + msg: "Docker Desktop is not running. Please start Docker Desktop." + when: ansible_facts['os_family'] == "Darwin" and docker_desktop_running is defined and docker_desktop_running.rc != 0 + +- name: Start and enable Docker service on Linux + service: + name: docker + state: started + enabled: true + when: ansible_facts['os_family'] == "Debian" + +- name: Ensure /etc/docker exists + file: + path: /etc/docker + state: directory + mode: "0755" + when: ansible_facts['os_family'] == "Debian" + +- name: Check if Docker daemon.json exists + stat: + path: /etc/docker/daemon.json + register: docker_daemon_json_stat + when: ansible_facts['os_family'] == "Debian" + +- name: Read existing Docker daemon.json + slurp: + path: /etc/docker/daemon.json + register: docker_daemon_json_slurp + when: + - ansible_facts['os_family'] == "Debian" + - docker_daemon_json_stat.stat.exists + +- name: Parse existing Docker daemon.json + set_fact: + docker_daemon_json_current: "{{ (docker_daemon_json_slurp.content | b64decode) | from_json }}" + when: + - ansible_facts['os_family'] == "Debian" + - docker_daemon_json_stat.stat.exists + +- name: Set empty Docker daemon.json config when missing + set_fact: + docker_daemon_json_current: {} + when: + - ansible_facts['os_family'] == "Debian" + - not docker_daemon_json_stat.stat.exists + +- name: Build desired Docker daemon.json config + set_fact: + docker_daemon_json_desired: >- + {{ + docker_daemon_json_current + | combine({ + 'log-driver': 'json-file', + 'log-opts': (docker_daemon_json_current['log-opts'] | default({})) + | combine({ + 'max-size': '10m', + 'max-file': '5' + }) + }, recursive=True) + }} + when: ansible_facts['os_family'] == "Debian" + +- name: Write Docker daemon.json + copy: + dest: /etc/docker/daemon.json + content: "{{ docker_daemon_json_desired | to_nice_json }}" + owner: root + group: root + mode: "0644" + register: docker_daemon_json_write + when: ansible_facts['os_family'] == "Debian" + +- name: Restart Docker when daemon.json changes + service: + name: docker + state: restarted + when: + - ansible_facts['os_family'] == "Debian" + - docker_daemon_json_write is changed diff --git a/roles/exporters/tasks/main.yml b/roles/exporters/tasks/main.yml new file mode 100644 index 0000000..581661f --- /dev/null +++ b/roles/exporters/tasks/main.yml @@ -0,0 +1,25 @@ +--- +- name: Create exporters directory + file: + path: /opt/exporters + state: directory + +- name: Ensure monitoring network exists + command: docker network inspect monitoring + register: monitoring_network + changed_when: false + failed_when: false + +- name: Create monitoring network if missing + command: docker network create monitoring + when: monitoring_network.rc != 0 + +- name: Copy Docker Compose file for exporters + template: + src: docker-compose.yml.j2 + dest: /opt/exporters/docker-compose.yml + +- name: Deploy exporters + command: docker compose up -d + args: + chdir: /opt/exporters diff --git a/roles/exporters/templates/docker-compose.yml.j2 b/roles/exporters/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..81a9811 --- /dev/null +++ b/roles/exporters/templates/docker-compose.yml.j2 @@ -0,0 +1,31 @@ +services: + node-exporter: + image: prom/node-exporter:v1.7.0 + command: + - --path.rootfs=/host + pid: host + volumes: + - /:/host:ro,rslave + networks: + - internal + restart: unless-stopped + labels: + - com.centurylinklabs.watchtower.enable=true + + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.49.1 + volumes: + - /:/rootfs:ro + - /var/run:/var/run:rw + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + networks: + - internal + restart: unless-stopped + labels: + - com.centurylinklabs.watchtower.enable=true + +networks: + internal: + external: true + name: monitoring diff --git a/roles/forgejo/tasks/main.yml b/roles/forgejo/tasks/main.yml new file mode 100644 index 0000000..79e5dae --- /dev/null +++ b/roles/forgejo/tasks/main.yml @@ -0,0 +1,55 @@ +--- +- name: Read OIDC client secret for Forgejo + set_fact: + forgejo_oidc_client_secret: "{{ AUTHELIA_OIDC_FORGEJO_CLIENT_SECRET | default(lookup('env', 'AUTHELIA_OIDC_FORGEJO_CLIENT_SECRET')) }}" + no_log: true + +- name: Fail if OIDC client secret for Forgejo is missing + fail: + msg: "AUTHELIA_OIDC_FORGEJO_CLIENT_SECRET is required" + when: forgejo_oidc_client_secret | length == 0 + +- name: Create Forgejo directory + file: + path: /opt/forgejo + state: directory + +- name: Copy Docker Compose file for Forgejo + template: + src: docker-compose.yml.j2 + dest: /opt/forgejo/docker-compose.yml + +- name: Deploy Forgejo + command: docker compose up -d --force-recreate + args: + chdir: /opt/forgejo + +- name: Run Forgejo database migrations + command: docker exec --user 1000:1000 forgejo-forgejo-1 forgejo migrate + changed_when: false + +- name: Configure Forgejo OIDC auth source (Authelia) + shell: | + set -euo pipefail + cid=$(docker ps -q --filter name=forgejo-forgejo-1 | head -n1) + if [ -z "$cid" ]; then + exit 1 + fi + + if docker exec --user 1000:1000 "$cid" forgejo admin auth list | grep -q "authelia"; then + exit 0 + fi + + docker exec --user 1000:1000 "$cid" forgejo admin auth add-oauth \ + --provider=openidConnect \ + --name=authelia \ + --key=forgejo \ + --secret="$FORGEJO_OIDC_CLIENT_SECRET" \ + --auto-discover-url=https://{{ auth_hostname }}/.well-known/openid-configuration \ + --scopes='openid email profile groups' \ + --group-claim-name=groups \ + --admin-group=admins + changed_when: false + environment: + FORGEJO_OIDC_CLIENT_SECRET: "{{ forgejo_oidc_client_secret }}" + no_log: true diff --git a/roles/forgejo/templates/docker-compose.yml.j2 b/roles/forgejo/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..0fb5611 --- /dev/null +++ b/roles/forgejo/templates/docker-compose.yml.j2 @@ -0,0 +1,38 @@ +services: + forgejo: + image: codeberg.org/forgejo/forgejo:9 + environment: + FORGEJO__server__DOMAIN: "{{ forgejo_hostname }}" + FORGEJO__server__ROOT_URL: "https://{{ forgejo_hostname }}/" + FORGEJO__server__SSH_DOMAIN: "{{ forgejo_hostname }}" + FORGEJO__server__SSH_PORT: "2222" + FORGEJO__server__DISABLE_SSH: "false" + FORGEJO__actions__ENABLED: "true" + FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true" + FORGEJO__service__DISABLE_REGISTRATION: "false" + FORGEJO__service__SHOW_REGISTRATION_BUTTON: "false" + FORGEJO__database__DB_TYPE: sqlite3 + volumes: + - forgejo_data:/data + ports: + - "2222:22" + networks: + - proxy + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.docker.network=proxy + - traefik.http.routers.forgejo.rule=Host(`{{ forgejo_hostname }}`) + - traefik.http.routers.forgejo.entrypoints=websecure + - traefik.http.routers.forgejo.tls=true + - traefik.http.routers.forgejo.tls.certresolver={{ traefik_certresolver }} + - traefik.http.routers.forgejo.middlewares=security-headers@file,compress@file + - traefik.http.services.forgejo.loadbalancer.server.port=3000 + - com.centurylinklabs.watchtower.enable=true + +volumes: + forgejo_data: + +networks: + proxy: + external: true diff --git a/roles/forgejo_runner/defaults/main.yml b/roles/forgejo_runner/defaults/main.yml new file mode 100644 index 0000000..37c5e03 --- /dev/null +++ b/roles/forgejo_runner/defaults/main.yml @@ -0,0 +1,5 @@ +--- +forgejo_runner_force_reregister: false + +forgejo_runner_labels: + - docker:docker://ghcr.io/catthehacker/ubuntu:act-22.04 \ No newline at end of file diff --git a/roles/forgejo_runner/tasks/main.yml b/roles/forgejo_runner/tasks/main.yml new file mode 100644 index 0000000..1a9a9db --- /dev/null +++ b/roles/forgejo_runner/tasks/main.yml @@ -0,0 +1,93 @@ +--- +- name: Read Forgejo runner registration token + set_fact: + forgejo_runner_registration_token: "{{ FORGEJO_RUNNER_REGISTRATION_TOKEN | default(lookup('env', 'FORGEJO_RUNNER_REGISTRATION_TOKEN')) }}" + no_log: true + +- name: Compute Forgejo runner labels + set_fact: + forgejo_runner_labels_csv: "{{ forgejo_runner_labels | join(',') }}" + +- name: Fail if Forgejo runner registration token is missing + fail: + msg: "FORGEJO_RUNNER_REGISTRATION_TOKEN is required" + when: forgejo_runner_registration_token | length == 0 + +- name: Create Forgejo runner directories + file: + path: "{{ item }}" + state: directory + owner: "1000" + group: "1000" + mode: "0775" + loop: + - /opt/forgejo-runner + - /opt/forgejo-runner/data + - /opt/forgejo-runner/data/.cache + +- name: Copy Docker Compose file for Forgejo runner + template: + src: docker-compose.yml.j2 + dest: /opt/forgejo-runner/docker-compose.yml + +- name: Force runner re-registration (reset local registration state) + file: + path: "{{ item }}" + state: absent + loop: + - /opt/forgejo-runner/data/.runner + - /opt/forgejo-runner/data/.labels + when: forgejo_runner_force_reregister | bool + +- name: Check whether Forgejo runner is already registered + stat: + path: /opt/forgejo-runner/data/.runner + register: forgejo_runner_registration + +- name: Check whether Forgejo runner labels file exists + stat: + path: /opt/forgejo-runner/data/.labels + register: forgejo_runner_labels_file + +- name: Read previously applied Forgejo runner labels (if any) + slurp: + src: /opt/forgejo-runner/data/.labels + register: forgejo_runner_labels_previous + when: forgejo_runner_labels_file.stat.exists + +- name: Determine whether Forgejo runner labels changed + set_fact: + forgejo_runner_labels_changed: >- + {{ (forgejo_runner_labels_previous.content | default('') | b64decode | trim) != (forgejo_runner_labels_csv | trim) }} + +- name: Remove runner registration when labels changed + file: + path: /opt/forgejo-runner/data/.runner + state: absent + when: forgejo_runner_labels_changed + +- name: Register Forgejo runner (one-time) + command: >- + docker compose run --rm runner forgejo-runner register + --no-interactive + --instance https://{{ forgejo_hostname }}/ + --token {{ forgejo_runner_registration_token }} + --name {{ inventory_hostname }} + --labels {{ forgejo_runner_labels_csv }} + args: + chdir: /opt/forgejo-runner + when: (not forgejo_runner_registration.stat.exists) or forgejo_runner_labels_changed + no_log: true + +- name: Persist applied Forgejo runner labels + copy: + dest: /opt/forgejo-runner/data/.labels + content: "{{ forgejo_runner_labels_csv }}" + owner: "1000" + group: "1000" + mode: "0644" + +- name: Deploy Forgejo runner + command: docker compose up -d --force-recreate + args: + chdir: /opt/forgejo-runner diff --git a/roles/forgejo_runner/templates/docker-compose.yml.j2 b/roles/forgejo_runner/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..1f8283b --- /dev/null +++ b/roles/forgejo_runner/templates/docker-compose.yml.j2 @@ -0,0 +1,13 @@ +services: + runner: + image: data.forgejo.org/forgejo/runner:11 + environment: + DOCKER_HOST: unix:///var/run/docker.sock + user: "0:0" + volumes: + - ./data:/data + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped + command: forgejo-runner daemon + labels: + - com.centurylinklabs.watchtower.enable=true diff --git a/roles/grafana/tasks/main.yml b/roles/grafana/tasks/main.yml new file mode 100644 index 0000000..8490720 --- /dev/null +++ b/roles/grafana/tasks/main.yml @@ -0,0 +1,44 @@ +--- +- name: Read Grafana admin password + set_fact: + grafana_admin_password: "{{ GRAFANA_ADMIN_PASSWORD | default(lookup('env', 'GRAFANA_ADMIN_PASSWORD')) }}" + +- name: Fail if Grafana admin password is missing + fail: + msg: "GRAFANA_ADMIN_PASSWORD is required" + when: grafana_admin_password | length == 0 + +- name: Create Grafana directory + file: + path: /opt/grafana + state: directory + +- name: Create Grafana provisioning directory + file: + path: /opt/grafana/provisioning/datasources + state: directory + +- name: Ensure monitoring network exists + command: docker network inspect monitoring + register: monitoring_network + changed_when: false + failed_when: false + +- name: Create monitoring network if missing + command: docker network create monitoring + when: monitoring_network.rc != 0 + +- name: Copy Docker Compose file for Grafana + template: + src: docker-compose.yml.j2 + dest: /opt/grafana/docker-compose.yml + +- name: Copy Grafana datasources provisioning + template: + src: datasources.yml.j2 + dest: /opt/grafana/provisioning/datasources/datasources.yml + +- name: Deploy Grafana + command: docker compose up -d --force-recreate + args: + chdir: /opt/grafana diff --git a/roles/grafana/templates/datasources.yml.j2 b/roles/grafana/templates/datasources.yml.j2 new file mode 100644 index 0000000..2c0808d --- /dev/null +++ b/roles/grafana/templates/datasources.yml.j2 @@ -0,0 +1,15 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + editable: false diff --git a/roles/grafana/templates/docker-compose.yml.j2 b/roles/grafana/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..c1adc32 --- /dev/null +++ b/roles/grafana/templates/docker-compose.yml.j2 @@ -0,0 +1,52 @@ +services: + grafana: + image: grafana/grafana:10.2.3 + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: "{{ grafana_admin_password }}" + GF_SERVER_ROOT_URL: "https://{{ grafana_hostname }}" + GF_AUTH_GENERIC_OAUTH_ENABLED: 'true' + GF_AUTH_GENERIC_OAUTH_NAME: 'Authelia' + GF_AUTH_GENERIC_OAUTH_ICON: 'signin' + GF_AUTH_GENERIC_OAUTH_CLIENT_ID: 'grafana' + GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: "{{ AUTHELIA_OIDC_GRAFANA_CLIENT_SECRET | default(lookup('env', 'AUTHELIA_OIDC_GRAFANA_CLIENT_SECRET')) }}" + GF_AUTH_GENERIC_OAUTH_SCOPES: 'openid profile email groups' + GF_AUTH_GENERIC_OAUTH_EMPTY_SCOPES: 'false' + GF_AUTH_GENERIC_OAUTH_AUTH_URL: 'https://{{ auth_hostname }}/api/oidc/authorization' + GF_AUTH_GENERIC_OAUTH_TOKEN_URL: 'https://{{ auth_hostname }}/api/oidc/token' + GF_AUTH_GENERIC_OAUTH_API_URL: 'https://{{ auth_hostname }}/api/oidc/userinfo' + GF_AUTH_OAUTH_ALLOW_INSECURE_EMAIL_LOOKUP: 'true' + GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH: 'preferred_username || sub' + GF_AUTH_GENERIC_OAUTH_GROUPS_ATTRIBUTE_PATH: 'groups' + GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH: 'name' + GF_AUTH_GENERIC_OAUTH_EMAIL_ATTRIBUTE_PATH: "email || (preferred_username && join('@', [preferred_username, 'jfraeys.com'])) || (sub && join('@', [sub, 'jfraeys.com']))" + GF_AUTH_GENERIC_OAUTH_USE_PKCE: 'true' + GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP: 'true' + GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH: "contains(groups[*], 'admins') && 'Admin' || 'Viewer'" + volumes: + - grafana_data:/var/lib/grafana + - ./provisioning:/etc/grafana/provisioning:ro + networks: + - monitoring + - proxy + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.docker.network=proxy + - traefik.http.routers.grafana.rule=Host(`{{ grafana_hostname }}`) + - traefik.http.routers.grafana.entrypoints=websecure + - traefik.http.routers.grafana.tls=true + - traefik.http.routers.grafana.tls.certresolver={{ traefik_certresolver }} + - traefik.http.routers.grafana.middlewares=security-headers@file,compress@file + - traefik.http.services.grafana.loadbalancer.server.port=3000 + - com.centurylinklabs.watchtower.enable=true + +volumes: + grafana_data: + +networks: + monitoring: + external: true + name: monitoring + proxy: + external: true diff --git a/roles/hardening/handlers/main.yml b/roles/hardening/handlers/main.yml new file mode 100644 index 0000000..b91e5e0 --- /dev/null +++ b/roles/hardening/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart rsyslog + service: + name: rsyslog + state: restarted diff --git a/roles/hardening/tasks/main.yml b/roles/hardening/tasks/main.yml new file mode 100644 index 0000000..75784f1 --- /dev/null +++ b/roles/hardening/tasks/main.yml @@ -0,0 +1,58 @@ +--- +- name: Install rsyslog + apt: + name: rsyslog + state: present + update_cache: true + +- name: Ensure rsyslog is enabled and running + service: + name: rsyslog + state: started + enabled: true + +- name: Configure rsyslog to write UFW kernel logs to /var/log/ufw.log + copy: + dest: /etc/rsyslog.d/20-ufw.conf + owner: root + group: root + mode: "0644" + content: | + :msg, contains, "[UFW " -/var/log/ufw.log + & stop + notify: Restart rsyslog + +- name: Ensure /var/log/ufw.log exists + file: + path: /var/log/ufw.log + state: touch + owner: root + group: adm + mode: "0640" + +- name: Configure logrotate for /var/log/ufw.log + copy: + dest: /etc/logrotate.d/ufw + owner: root + group: root + mode: "0644" + content: | + /var/log/ufw.log { + daily + missingok + rotate 14 + compress + delaycompress + notifempty + create 0640 root adm + sharedscripts + postrotate + systemctl reload rsyslog > /dev/null 2>&1 || true + endscript + } + +- name: Set UFW logging level to low + command: ufw logging low + register: ufw_logging + changed_when: "'Logging enabled' in ufw_logging.stdout or 'Logging:' in ufw_logging.stdout" + failed_when: false diff --git a/roles/lldap/tasks/main.yml b/roles/lldap/tasks/main.yml new file mode 100644 index 0000000..dce8550 --- /dev/null +++ b/roles/lldap/tasks/main.yml @@ -0,0 +1,45 @@ +--- +- name: Read LLDAP admin password + set_fact: + lldap_admin_password: "{{ LLDAP_ADMIN_PASSWORD | default(lookup('env', 'LLDAP_ADMIN_PASSWORD')) }}" + no_log: true + +- name: Fail if LLDAP admin password is missing + fail: + msg: "LLDAP_ADMIN_PASSWORD is required" + when: lldap_admin_password | length == 0 + +- name: Read LLDAP JWT secret + set_fact: + lldap_jwt_secret: "{{ LLDAP_JWT_SECRET | default(lookup('env', 'LLDAP_JWT_SECRET')) }}" + no_log: true + +- name: Fail if LLDAP JWT secret is missing + fail: + msg: "LLDAP_JWT_SECRET is required" + when: lldap_jwt_secret | length == 0 + +- name: Read LLDAP key seed + set_fact: + lldap_key_seed: "{{ LLDAP_KEY_SEED | default(lookup('env', 'LLDAP_KEY_SEED')) }}" + no_log: true + +- name: Fail if LLDAP key seed is missing + fail: + msg: "LLDAP_KEY_SEED is required" + when: lldap_key_seed | length == 0 + +- name: Create LLDAP directory + file: + path: /opt/lldap + state: directory + +- name: Copy Docker Compose file for LLDAP + template: + src: docker-compose.yml.j2 + dest: /opt/lldap/docker-compose.yml + +- name: Deploy LLDAP + command: docker compose up -d --force-recreate + args: + chdir: /opt/lldap diff --git a/roles/lldap/templates/docker-compose.yml.j2 b/roles/lldap/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..c9c69d3 --- /dev/null +++ b/roles/lldap/templates/docker-compose.yml.j2 @@ -0,0 +1,23 @@ +services: + lldap: + image: lldap/lldap:stable + environment: + LLDAP_JWT_SECRET: "{{ lldap_jwt_secret }}" + LLDAP_KEY_SEED: "{{ lldap_key_seed }}" + LLDAP_LDAP_BASE_DN: "{{ lldap_base_dn }}" + LLDAP_LDAP_USER_DN: "admin" + LLDAP_LDAP_USER_PASS: "{{ lldap_admin_password }}" + volumes: + - lldap_data:/data + ports: + - "127.0.0.1:17170:17170" + networks: + - proxy + restart: unless-stopped + +volumes: + lldap_data: + +networks: + proxy: + external: true diff --git a/roles/loki/tasks/main.yml b/roles/loki/tasks/main.yml new file mode 100644 index 0000000..370ddef --- /dev/null +++ b/roles/loki/tasks/main.yml @@ -0,0 +1,60 @@ +--- +- name: Read web public IPv4 from inventory + set_fact: + loki_web_public_ipv4: "{{ (hostvars.get('web', {})).get('public_ipv4', '') }}" + +- name: Warn if web public IPv4 is not set (skipping Loki allowlist) + debug: + msg: "web public_ipv4 is not set in inventory; skipping Loki UFW allowlist/deny rules." + when: loki_web_public_ipv4 | length == 0 + +- name: Ensure UFW is installed + apt: + name: ufw + state: present + +- name: Enable UFW + command: ufw --force enable + changed_when: false + +- name: Allowlist Loki from web host (insert rule at top) + command: "ufw insert 1 allow from {{ loki_web_public_ipv4 }} to any port 3100 proto tcp" + register: ufw_allow_loki + changed_when: "'Rule inserted' in ufw_allow_loki.stdout or 'Rules updated' in ufw_allow_loki.stdout" + when: loki_web_public_ipv4 | length > 0 + +- name: Deny Loki from everyone else + command: ufw deny 3100/tcp + register: ufw_deny_loki + changed_when: "'Rule inserted' in ufw_deny_loki.stdout or 'Rules updated' in ufw_deny_loki.stdout" + when: loki_web_public_ipv4 | length > 0 + +- name: Create Loki directory + file: + path: /opt/loki + state: directory + +- name: Ensure monitoring network exists + command: docker network inspect monitoring + register: monitoring_network + changed_when: false + failed_when: false + +- name: Create monitoring network if missing + command: docker network create monitoring + when: monitoring_network.rc != 0 + +- name: Copy Loki configuration + template: + src: loki-config.yml.j2 + dest: /opt/loki/loki-config.yml + +- name: Copy Docker Compose file for Loki + template: + src: docker-compose.yml.j2 + dest: /opt/loki/docker-compose.yml + +- name: Deploy Loki + command: docker compose up -d + args: + chdir: /opt/loki diff --git a/roles/loki/templates/docker-compose.yml.j2 b/roles/loki/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..c0932d9 --- /dev/null +++ b/roles/loki/templates/docker-compose.yml.j2 @@ -0,0 +1,22 @@ +services: + loki: + image: grafana/loki:2.9.4 + command: -config.file=/etc/loki/config.yml + ports: + - "3100:3100" + volumes: + - ./loki-config.yml:/etc/loki/config.yml:ro + - loki_data:/loki + networks: + - monitoring + restart: unless-stopped + labels: + - com.centurylinklabs.watchtower.enable=true + +volumes: + loki_data: + +networks: + monitoring: + external: true + name: monitoring diff --git a/roles/loki/templates/loki-config.yml.j2 b/roles/loki/templates/loki-config.yml.j2 new file mode 100644 index 0000000..e871678 --- /dev/null +++ b/roles/loki/templates/loki-config.yml.j2 @@ -0,0 +1,31 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2020-10-24 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + +ruler: + storage: + type: local + local: + directory: /loki/rules diff --git a/roles/prometheus/tasks/main.yml b/roles/prometheus/tasks/main.yml new file mode 100644 index 0000000..64f9169 --- /dev/null +++ b/roles/prometheus/tasks/main.yml @@ -0,0 +1,30 @@ +--- +- name: Create Prometheus directory + file: + path: /opt/prometheus + state: directory + +- name: Ensure monitoring network exists + command: docker network inspect monitoring + register: monitoring_network + changed_when: false + failed_when: false + +- name: Create monitoring network if missing + command: docker network create monitoring + when: monitoring_network.rc != 0 + +- name: Copy Prometheus configuration + template: + src: prometheus.yml.j2 + dest: /opt/prometheus/prometheus.yml + +- name: Copy Docker Compose file for Prometheus + template: + src: docker-compose.yml.j2 + dest: /opt/prometheus/docker-compose.yml + +- name: Deploy Prometheus + command: docker compose up -d + args: + chdir: /opt/prometheus diff --git a/roles/prometheus/templates/docker-compose.yml.j2 b/roles/prometheus/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..c51ebf8 --- /dev/null +++ b/roles/prometheus/templates/docker-compose.yml.j2 @@ -0,0 +1,23 @@ +services: + prometheus: + image: prom/prometheus:v2.49.1 + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time=15d + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + networks: + - monitoring + restart: unless-stopped + labels: + - com.centurylinklabs.watchtower.enable=true + +volumes: + prometheus_data: + +networks: + monitoring: + external: true + name: monitoring diff --git a/roles/prometheus/templates/prometheus.yml.j2 b/roles/prometheus/templates/prometheus.yml.j2 new file mode 100644 index 0000000..6d54b58 --- /dev/null +++ b/roles/prometheus/templates/prometheus.yml.j2 @@ -0,0 +1,15 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ['prometheus:9090'] + + - job_name: services-node + static_configs: + - targets: ['node-exporter:9100'] + + - job_name: services-cadvisor + static_configs: + - targets: ['cadvisor:8080'] diff --git a/roles/spark/tasks/main.yml b/roles/spark/tasks/main.yml new file mode 100644 index 0000000..d374fe4 --- /dev/null +++ b/roles/spark/tasks/main.yml @@ -0,0 +1,4 @@ +--- +- name: Spark role placeholder + debug: + msg: "Spark role is not implemented yet (deploy_spark is optional)." diff --git a/roles/traefik/handlers/main.yml b/roles/traefik/handlers/main.yml new file mode 100644 index 0000000..ec295dd --- /dev/null +++ b/roles/traefik/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart Traefik + docker_container: + name: traefik + state: restarted diff --git a/roles/traefik/tasks/main.yml b/roles/traefik/tasks/main.yml new file mode 100644 index 0000000..e7313a5 --- /dev/null +++ b/roles/traefik/tasks/main.yml @@ -0,0 +1,134 @@ +--- +- name: Determine Traefik directory + set_fact: + traefik_dir: >- + {{ + '/opt/traefik' if not use_temp_dir else + (traefik_tempdir.path if use_temp_dir | default(false) + else '/opt/traefik') + }} + +- name: Read Cloudflare DNS API token + set_fact: + traefik_cloudflare_dns_api_token: >- + {{ + CF_DNS_API_TOKEN + | default(lookup('env', 'CF_DNS_API_TOKEN')) + | default(TF_VAR_cloudflare_api_token) + | default(lookup('env', 'TF_VAR_cloudflare_api_token')) + }} + +- name: Fail if Cloudflare DNS API token is missing + fail: + msg: "CF_DNS_API_TOKEN (recommended) or TF_VAR_cloudflare_api_token is required for Traefik DNS-01" + when: traefik_cloudflare_dns_api_token | length == 0 + +- name: Create permanent directory for Traefik Docker Compose + file: + path: /opt/traefik + state: directory + when: not use_temp_dir + +- name: Create temporary directory for Traefik Docker Compose (for testing) + tempfile: + state: directory + suffix: traefik + register: traefik_tempdir + when: use_temp_dir | default(false) + +- name: Copy Docker Compose file for Traefik + template: + src: home-docker-compose.yml.j2 + dest: "{{ traefik_dir }}/docker-compose.yml" + +- name: Create Traefik subdirectories + file: + path: "{{ traefik_dir }}/{{ item }}" + state: directory + loop: + - letsencrypt + - dynamic + +- name: Ensure ACME storage file exists + file: + path: "{{ traefik_dir }}/letsencrypt/acme.json" + state: touch + mode: "0600" + +- name: Copy base dynamic configuration + copy: + dest: "{{ traefik_dir }}/dynamic/base.yml" + content: | + http: + routers: + authelia: + rule: "Host(`{{ auth_hostname }}`)" + entryPoints: + - websecure + tls: + certResolver: "{{ traefik_certresolver }}" + service: authelia + middlewares: + - security-headers + - compress + + grafana: + rule: "Host(`{{ grafana_hostname }}`)" + entryPoints: + - websecure + tls: + certResolver: "{{ traefik_certresolver }}" + service: grafana + middlewares: + - security-headers + - compress + + forgejo: + rule: "Host(`{{ forgejo_hostname }}`)" + entryPoints: + - websecure + tls: + certResolver: "{{ traefik_certresolver }}" + service: forgejo + middlewares: + - security-headers + - compress + + services: + authelia: + loadBalancer: + servers: + - url: "http://authelia:9091" + grafana: + loadBalancer: + servers: + - url: "http://grafana:3000" + forgejo: + loadBalancer: + servers: + - url: "http://forgejo:3000" + + middlewares: + security-headers: + headers: + frameDeny: true + contentTypeNosniff: true + browserXssFilter: true + referrerPolicy: "no-referrer" + compress: + compress: {} + +- name: Ensure proxy network exists + command: docker network inspect proxy + register: proxy_network + changed_when: false + failed_when: false + +- name: Create proxy network if missing + command: docker network create proxy + when: proxy_network.rc != 0 + +- name: Deploy Traefik container + command: docker compose up -d --force-recreate + args: + chdir: "{{ traefik_dir }}" diff --git a/roles/traefik/templates/home-docker-compose.yml.j2 b/roles/traefik/templates/home-docker-compose.yml.j2 new file mode 100644 index 0000000..6aab54d --- /dev/null +++ b/roles/traefik/templates/home-docker-compose.yml.j2 @@ -0,0 +1,31 @@ +services: + traefik: + image: traefik:v2.11.10 + command: + - --api.dashboard=true + - --providers.file.directory=/etc/traefik/dynamic + - --providers.file.watch=true + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.{{ traefik_certresolver }}.acme.email={{ traefik_acme_email }} + - --certificatesresolvers.{{ traefik_certresolver }}.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.{{ traefik_certresolver }}.acme.dnschallenge=true + - --certificatesresolvers.{{ traefik_certresolver }}.acme.dnschallenge.provider=cloudflare + - --certificatesresolvers.{{ traefik_certresolver }}.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53 + ports: + - "80:80" + - "443:443" + environment: + - CF_DNS_API_TOKEN={{ traefik_cloudflare_dns_api_token }} + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - {{ traefik_dir }}/letsencrypt:/letsencrypt + - {{ traefik_dir }}/dynamic:/etc/traefik/dynamic + networks: + - proxy + restart: always + +networks: + proxy: + external: true + diff --git a/roles/traefik/vars/main.yml b/roles/traefik/vars/main.yml new file mode 100644 index 0000000..ec83afa --- /dev/null +++ b/roles/traefik/vars/main.yml @@ -0,0 +1,2 @@ +--- +use_temp_dir: "{{ inventory_hostname == 'localhost' }}" diff --git a/roles/watchtower/tasks/main.yml b/roles/watchtower/tasks/main.yml new file mode 100644 index 0000000..0feafcd --- /dev/null +++ b/roles/watchtower/tasks/main.yml @@ -0,0 +1,39 @@ +--- +- name: Create Watchtower directory + file: + path: /opt/watchtower + state: directory + +- name: Copy Docker Compose file for Watchtower + template: + src: docker-compose.yml.j2 + dest: /opt/watchtower/docker-compose.yml + +- name: Deploy Watchtower + command: docker compose up -d + args: + chdir: /opt/watchtower + +- name: Wait for Watchtower service to be running + command: docker compose ps --services --filter status=running + args: + chdir: /opt/watchtower + register: watchtower_running + changed_when: false + retries: 10 + delay: 3 + until: "'watchtower' in (watchtower_running.stdout | default(''))" + +- name: Read Watchtower logs if not running + command: docker compose logs --no-color --tail=200 + args: + chdir: /opt/watchtower + register: watchtower_logs + changed_when: false + failed_when: false + when: "'watchtower' not in (watchtower_running.stdout | default(''))" + +- name: Fail if Watchtower is not running + fail: + msg: "Watchtower is not running. docker compose ps output: {{ watchtower_running.stdout | default('') }}\n\nLogs:\n{{ watchtower_logs.stdout | default('') }}\n{{ watchtower_logs.stderr | default('') }}" + when: "'watchtower' not in (watchtower_running.stdout | default(''))" diff --git a/roles/watchtower/templates/docker-compose.yml.j2 b/roles/watchtower/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..add70d3 --- /dev/null +++ b/roles/watchtower/templates/docker-compose.yml.j2 @@ -0,0 +1,9 @@ +services: + watchtower: + image: containrrr/watchtower:1.7.1 + command: --label-enable --cleanup --interval 3600 + environment: + DOCKER_API_VERSION: "1.44" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped diff --git a/scripts/gen-auth-secrets.sh b/scripts/gen-auth-secrets.sh new file mode 100644 index 0000000..fe1ab9f --- /dev/null +++ b/scripts/gen-auth-secrets.sh @@ -0,0 +1,37 @@ +#! /usr/bin/env bash + +set -euo pipefail + +rand_hex() { + local bytes="$1" + openssl rand -hex "${bytes}" +} + +LLDAP_ADMIN_PASSWORD=$(rand_hex 16) +LLDAP_JWT_SECRET=$(rand_hex 32) +LLDAP_KEY_SEED=$(rand_hex 32) + +AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET=$(rand_hex 32) +AUTHELIA_SESSION_SECRET=$(rand_hex 32) +AUTHELIA_STORAGE_ENCRYPTION_KEY=$(rand_hex 32) +AUTHELIA_OIDC_HMAC_SECRET=$(rand_hex 32) + +AUTHELIA_OIDC_GRAFANA_CLIENT_SECRET=$(rand_hex 20) +AUTHELIA_OIDC_FORGEJO_CLIENT_SECRET=$(rand_hex 20) + +OIDC_PRIVATE_KEY_PEM=$(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null) + +cat < "${temp_vault_pass_file}" + unset vault_password + vault_args+=(--vault-password-file "${temp_vault_pass_file}") + fi + + if (( ${#vault_args[@]} )); then + vault_plain=$(ansible-vault view secrets/vault.yml "${vault_args[@]}") + else + vault_plain=$(ansible-vault view secrets/vault.yml) + fi + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + [[ "${line}" == "---" ]] && continue + [[ "${line}" != TF_VAR_*:* ]] && [[ "${line}" != CF_DNS_API_TOKEN:* ]] && [[ "${line}" != S3_ACCESS_KEY_ID:* ]] && [[ "${line}" != S3_SECRET_ACCESS_KEY:* ]] && continue + key="${line%%:*}" + value="${line#*:}" + value="${value# }" + [[ -z "${value}" ]] && continue + escaped=$(printf '%q' "${value}") + eval "export ${key}=${escaped}" + done <<< "${vault_plain}" + + if [[ -z "${CF_DNS_API_TOKEN:-}" ]] && [[ -n "${TF_VAR_cloudflare_api_token:-}" ]]; then + export CF_DNS_API_TOKEN="${TF_VAR_cloudflare_api_token}" + fi +fi + +terraform -chdir=terraform init + +if (( ${#terraform_passthrough[@]} )); then + terraform -chdir=terraform "${terraform_passthrough[@]}" + exit 0 +fi + +if (( ${#terraform_apply_args[@]} )); then + terraform -chdir=terraform apply "${terraform_apply_args[@]}" +else + terraform -chdir=terraform plan -out=tfplan + terraform -chdir=terraform apply tfplan +fi + +rm -f terraform/tfplan + +web_ipv4=$(terraform -chdir=terraform output -raw web_ip) +services_ipv4=$(terraform -chdir=terraform output -raw services_ip) + +ssh_user=${TF_VAR_user:-ansible} + +mkdir -p inventory/host_vars + +cat > inventory/hosts.yml < inventory/host_vars/web.yml < >(tee -i /var/log/stackscript.log) 2>&1 +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive +export NEEDRESTART_MODE=a + +# +# +# +# +# +# +# +# + +touch ~/.hushlogin + +echo "Updating system..." +apt-get update + +apt-get install -y sudo openssh-server + +echo "Setting hostname to $NAME" +hostnamectl set-hostname "${NAME}" || true + +: "${SSH_USER:=ansible}" +: "${USER_PASSWORD:=}" + +echo "Creating user $SSH_USER" +if ! id -u "${SSH_USER}" >/dev/null 2>&1; then + useradd -m -s /bin/bash "${SSH_USER}" +fi + +if [ -n "${USER_PASSWORD}" ]; then + echo "${SSH_USER}:${USER_PASSWORD}" | chpasswd +fi + +groupadd -f sudo +usermod -aG sudo "${SSH_USER}" +mkdir -p /etc/sudoers.d +cat > "/etc/sudoers.d/90-${SSH_USER}" <&2 + exit 1 +fi + +if [ -n "${GROUP}" ]; then + groupadd -f "${GROUP}" + usermod -aG "${GROUP}" "${SSH_USER}" +fi + +# SSH setup +echo "Configuring SSH..." +mkdir -p "${USER_HOME}"/.ssh + +for i in $(seq 1 60); do + if [ -s /root/.ssh/authorized_keys ]; then + break + fi + sleep 2 +done + +if [ -s /root/.ssh/authorized_keys ]; then + cp /root/.ssh/authorized_keys "${USER_HOME}"/.ssh/authorized_keys +else + if [ -n "${SSH_PUBLIC_KEY:-}" ]; then + printf '%s\n' "${SSH_PUBLIC_KEY}" > "${USER_HOME}"/.ssh/authorized_keys + else + echo "No /root/.ssh/authorized_keys and no SSH_PUBLIC_KEY provided" >&2 + exit 1 + fi +fi + +if [ -n "${SSH_PUBLIC_KEY:-}" ]; then + if ! grep -qF "${SSH_PUBLIC_KEY}" "${USER_HOME}"/.ssh/authorized_keys; then + printf '%s\n' "${SSH_PUBLIC_KEY}" >> "${USER_HOME}"/.ssh/authorized_keys + fi +fi + +chown -R "${SSH_USER}:${SSH_USER}" "${USER_HOME}"/.ssh +chmod 700 "${USER_HOME}"/.ssh +chmod 600 "${USER_HOME}"/.ssh/authorized_keys + +chown "${SSH_USER}:${SSH_USER}" "${USER_HOME}" +chmod 755 "${USER_HOME}" +chmod go-w "${USER_HOME}" + +mkdir -p /etc/ssh/sshd_config.d +cat > /etc/ssh/sshd_config.d/99-infra.conf < /etc/sysctl.d/99-console-quiet.conf < /etc/apt/sources.list.d/docker.list +apt-get update +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +usermod -aG docker "${SSH_USER}" +systemctl enable docker + +# Docker Compose (v2 plugin installed above) + +# Ansible +echo "Installing Ansible..." +pip3 install ansible + +# Fail2ban +echo "Configuring Fail2ban..." +cat > /etc/fail2ban/jail.d/sshd.local < /etc/logrotate.d/custom < /dev/null 2>/dev/null || true + endscript +} +EOF + +# Optional: NTP (Systemd handles this well now) +timedatectl set-ntp true + +# Cleanup +echo "Cleaning up..." +history -c +rm -f /root/.bash_history /home/${SSH_USER}/.bash_history || true +unset NAME GROUP SSH_USER USER_PASSWORD SSH_PUBLIC_KEY SSH_PORT TIMEZONE ADD_CLOUDFLARE_IPS + +echo "StackScript complete. Server ready." diff --git a/stackscripts/services.sh b/stackscripts/services.sh new file mode 100644 index 0000000..d4c6604 --- /dev/null +++ b/stackscripts/services.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +exec > >(tee -i /var/log/stackscript.log) 2>&1 +set -euo pipefail + +# UDF fields +# +# +# +# +# +# +# +# + +source +source +touch ~/.hushlogin + +echo "Services StackScript completed successfully!" diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..36c468c --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,47 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "4.52.5" + constraints = "~> 4.0" + hashes = [ + "h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=", + "zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b", + "zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a", + "zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7", + "zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238", + "zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b", + "zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072", + "zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661", + "zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54", + "zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852", + "zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0", + "zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5", + "zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036", + "zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803", + ] +} + +provider "registry.terraform.io/linode/linode" { + version = "2.41.2" + constraints = "~> 2.0" + hashes = [ + "h1:GZjEpAHVD35fcAdrOzIC2TLDJPgg5TjnxSuoOqw/GnQ=", + "zh:04b3e099349777d46c23242b1b217577c00a22a8a282759b0ea10f39fbe5295e", + "zh:24b6a94a309c6887a5e0080cd1c389874c93e35013774c30648d8d6f871cccf7", + "zh:522e2ca78c4c96cdfd96982acaca8f5d1886cc14cdb0d2355dfa6b0a9d12a19c", + "zh:590de3a70478c991d403ed8159c401d864927b3e62ac37aaec2e8a3c557f4c8a", + "zh:6534425a180d9962170b6a9b4f0c80a755d1ef9a9b4b5458fd979a0524e27fd0", + "zh:a0143448cf3f8f03ced3d8f64b58ce862da096d2af76a60b5918dc9179a495e6", + "zh:b593fe9f060e413a304de88ada4a22d9937549b1df0d4fe86d6c205bc2df5ece", + "zh:c05503fad80e9e83283a04d063b36cec0e5b573ce9ebc3be4977728ae4fe6f45", + "zh:d06165ad07b60507b72197b83499d565588a7c3dcae1563dbf7d1512878e5cd8", + "zh:e5ff60aed05b8cd5fc8b39f5a05fe5b9657dd8b78bcee940d3b594eb15c52fd7", + "zh:ed1ffe36c000df9116dfde52f6b0994c2af39d8836e1d9ee1bed07f6cc502552", + "zh:ed86e977142f90b5be547efe61d5ff4042c234816e67c0e5a7e252c5fba7e357", + "zh:eda5a32dd2dd3fea914d28daee0b56201785026a989c7f3541e38d0782277683", + "zh:ee9d3f51b28d0d44a30f91462ad94371bd64210d050fec5765f1c8dafc9ee35d", + ] +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..064a4c8 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,275 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + linode = { + source = "linode/linode" + version = "~> 2.0" + } + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 4.0" + } + } +} + +provider "linode" { + token = var.linode_token +} + +provider "cloudflare" { + api_token = var.cloudflare_api_token +} + +resource "linode_stackscript" "essentials" { + label = "essentials" + description = "Baseline server init (SSH hardening, UFW, Docker, Ansible, etc.)" + images = [var.image] + rev_note = "managed by terraform" + script = file("${path.module}/../stackscripts/essentials.sh") +} + +resource "linode_stackscript" "services" { + label = "services" + description = "Services node init (runs essentials + services specific steps)" + images = [var.image] + rev_note = "managed by terraform" + + script = replace( + file("${path.module}/../stackscripts/services.sh"), + "__ESSENTIALS_STACKSCRIPT_ID__", + tostring(linode_stackscript.essentials.id) + ) +} + +resource "linode_instance" "web" { + label = var.web_label + region = var.region + type = var.instance_type + + image = var.image + root_pass = var.root_pass + + authorized_keys = [var.ssh_public_key] + + stackscript_id = linode_stackscript.essentials.id + stackscript_data = { + NAME = var.web_label + GROUP = var.group + SSH_USER = var.user + USER_PASSWORD = var.user_password + SSH_PUBLIC_KEY = var.ssh_public_key + SSH_PORT = var.ssh_port + TIMEZONE = var.timezone + ADD_CLOUDFLARE_IPS = var.add_cloudflare_ips + } + + lifecycle { + ignore_changes = [ + root_pass, + stackscript_id, + stackscript_data, + ] + } +} + +resource "linode_instance" "services" { + label = var.services_label + region = var.region + type = var.instance_type + + image = var.image + root_pass = var.root_pass + + authorized_keys = [var.ssh_public_key] + + stackscript_id = linode_stackscript.services.id + stackscript_data = { + NAME = var.services_label + GROUP = var.group + SSH_USER = var.user + USER_PASSWORD = var.user_password + SSH_PUBLIC_KEY = var.ssh_public_key + SSH_PORT = var.ssh_port + TIMEZONE = var.timezone + ADD_CLOUDFLARE_IPS = var.add_cloudflare_ips + } + + lifecycle { + ignore_changes = [ + root_pass, + stackscript_id, + stackscript_data, + ] + } +} + +resource "cloudflare_record" "root_a" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "@" + type = "A" + content = sort(tolist(linode_instance.web.ipv4))[0] + ttl = 1 + proxied = true +} + +resource "cloudflare_record" "root_aaaa" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "@" + type = "AAAA" + content = split("/", linode_instance.web.ipv6)[0] + ttl = 1 + proxied = true +} + +resource "cloudflare_record" "www_a" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "www" + type = "A" + content = sort(tolist(linode_instance.web.ipv4))[0] + ttl = 1 + proxied = true +} + +resource "cloudflare_record" "www_aaaa" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "www" + type = "AAAA" + content = split("/", linode_instance.web.ipv6)[0] + ttl = 1 + proxied = true +} + +resource "cloudflare_record" "services_a" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "services" + type = "A" + content = sort(tolist(linode_instance.services.ipv4))[0] + ttl = 1 + proxied = true +} + +resource "cloudflare_record" "services_aaaa" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "services" + type = "AAAA" + content = split("/", linode_instance.services.ipv6)[0] + ttl = 1 + proxied = true +} + +resource "cloudflare_record" "grafana_a" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "grafana" + type = "A" + content = sort(tolist(linode_instance.services.ipv4))[0] + ttl = 1 + proxied = true +} + +resource "cloudflare_record" "grafana_aaaa" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "grafana" + type = "AAAA" + content = split("/", linode_instance.services.ipv6)[0] + ttl = 1 + proxied = true +} + +resource "cloudflare_record" "auth_a" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "auth" + type = "A" + content = sort(tolist(linode_instance.services.ipv4))[0] + ttl = 1 + proxied = false +} + +resource "cloudflare_record" "auth_aaaa" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "auth" + type = "AAAA" + content = split("/", linode_instance.services.ipv6)[0] + ttl = 1 + proxied = false +} + +resource "cloudflare_record" "git_a" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "git" + type = "A" + content = sort(tolist(linode_instance.services.ipv4))[0] + ttl = 1 + proxied = false +} + +resource "cloudflare_record" "git_aaaa" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "git" + type = "AAAA" + content = split("/", linode_instance.services.ipv6)[0] + ttl = 1 + proxied = false +} + +resource "cloudflare_record" "mail_a" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "mail" + type = "A" + content = sort(tolist(linode_instance.web.ipv4))[0] + ttl = var.cloudflare_ttl + proxied = false +} + +resource "cloudflare_record" "mail_aaaa" { + count = var.enable_cloudflare_dns ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "mail" + type = "AAAA" + content = split("/", linode_instance.web.ipv6)[0] + ttl = var.cloudflare_ttl + proxied = false +} + +resource "cloudflare_record" "services_wildcard_a" { + count = (var.enable_cloudflare_dns && var.enable_services_wildcard) ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "*.services" + type = "A" + content = sort(tolist(linode_instance.services.ipv4))[0] + ttl = 1 + proxied = false +} + +resource "cloudflare_record" "services_wildcard_aaaa" { + count = (var.enable_cloudflare_dns && var.enable_services_wildcard) ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "*.services" + type = "AAAA" + content = split("/", linode_instance.services.ipv6)[0] + ttl = 1 + proxied = false +} + +resource "cloudflare_record" "blizzard_cname" { + count = (var.enable_cloudflare_dns && length(var.object_storage_bucket) > 0 && length(var.object_storage_region) > 0) ? 1 : 0 + zone_id = var.cloudflare_zone_id + name = "blizzard" + type = "CNAME" + content = "${var.object_storage_bucket}.${var.object_storage_region}.linodeobjects.com" + ttl = var.cloudflare_ttl + proxied = false +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..fa10d8b --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,31 @@ +output "web_ip" { + value = sort(tolist(linode_instance.web.ipv4))[0] +} + +output "web_ipv6" { + value = linode_instance.web.ipv6 +} + +output "web_status" { + value = linode_instance.web.status +} + +output "services_ip" { + value = sort(tolist(linode_instance.services.ipv4))[0] +} + +output "services_ipv6" { + value = linode_instance.services.ipv6 +} + +output "services_status" { + value = linode_instance.services.status +} + +output "essentials_stackscript_id" { + value = linode_stackscript.essentials.id +} + +output "services_stackscript_id" { + value = linode_stackscript.services.id +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..d7a7143 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,109 @@ +variable "linode_token" { + type = string + sensitive = true +} + +variable "region" { + type = string + default = "ca-central" +} + +variable "instance_type" { + type = string + default = "g6-nanode-1" +} + +variable "image" { + type = string + default = "linode/debian13" +} + +variable "ssh_public_key" { + type = string +} + +variable "root_pass" { + type = string + sensitive = true +} + +variable "web_label" { + type = string + default = "web" +} + +variable "services_label" { + type = string + default = "services" +} + +variable "user" { + type = string + default = "ansible" +} + +variable "user_password" { + type = string + sensitive = true +} + +variable "group" { + type = string + default = "" +} + +variable "ssh_port" { + type = number + default = 22 +} + +variable "timezone" { + type = string + default = "America/Toronto" +} + +variable "add_cloudflare_ips" { + type = bool + default = false +} + +variable "cloudflare_api_token" { + type = string + sensitive = true + default = "" +} + +variable "cloudflare_zone_id" { + type = string + default = "" +} + +variable "enable_cloudflare_dns" { + type = bool + default = false +} + +variable "enable_services_wildcard" { + type = bool + default = false +} + +variable "cloudflare_ttl" { + type = number + default = 300 +} + +variable "cloudflare_proxied" { + type = bool + default = false +} + +variable "object_storage_bucket" { + type = string + default = "" +} + +variable "object_storage_region" { + type = string + default = "us-east-1" +}