commit ae4f1ad1cce1c39d11e33e5d244cee4e00ac64b8 Author: Ubuntu Date: Thu Jan 8 19:41:23 2026 +0000 initial upload diff --git a/deployments/vm-docker-apps-301.stabify.de.yml b/deployments/vm-docker-apps-301.stabify.de.yml new file mode 100644 index 0000000..43df03b --- /dev/null +++ b/deployments/vm-docker-apps-301.stabify.de.yml @@ -0,0 +1,3 @@ +apps: + - vault + diff --git a/infrastructure/.gitignore b/infrastructure/.gitignore new file mode 100644 index 0000000..450cbda --- /dev/null +++ b/infrastructure/.gitignore @@ -0,0 +1 @@ +acme/ diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 0000000..a015c10 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,162 @@ +# Infrastructure Deployment Guide + +Dieses Dokument führt dich durch den **kompletten** Deployment-Prozess ("From Scratch") der Stabify Infrastruktur. Es erklärt sowohl das initiale Setup (Bootstrapping) als auch den automatisierten Regelbetrieb (GitOps). + +## Übersicht: Wie alles zusammenhängt + +Wir nutzen ein **GitOps** Modell: Der Code in diesem Repository ist die "Source of Truth". +* **Terraform** erstellt die Hardware (VMs). +* **Ansible** konfiguriert die Software (Docker, Apps). +* **Vault** speichert alle Geheimnisse (Passwörter, Tokens). + +### Der Secret-Flow (Wer bekommt Secrets woher?) + +| Phase | Wer braucht Secrets? | Woher kommen sie? | Authentifizierung | +| :--- | :--- | :--- | :--- | +| **1. Terraform** | Dein PC | Vault (Remote via HTTPS) | Dein `VAULT_TOKEN` Env-Var | +| **2. Ansible (Push)** | Dein PC | Vault (Remote via HTTPS) | Dein `VAULT_TOKEN` Env-Var | +| **3. Ansible (Pull)** | Die VM selbst | Vault (Intern via HTTPS) | Token auf der VM (`/root/.vault-token`) | + +--- + +## Voraussetzungen + +* Zugriff auf Proxmox API und OPNsense API. +* Installiert: `terraform`, `ansible`, `sshpass`. +* SSH-Key für Ansible liegt bereit (z.B. `~/.ssh/id_ed25519.pub`). + +--- + +## Phase 1: Bootstrap (Henne-Ei-Problem lösen) + +Wir wollen Vault nutzen, aber Vault läuft selbst auf einer VM, die wir erst erstellen müssen. Daher müssen wir Terraform und Ansible einmalig "dumm" (ohne Vault) betreiben. + +1. **Erstelle eine `bootstrap.tfvars`** (Diese Datei **NICHT** committen!): + + ```hcl + # terraform/bootstrap.tfvars + use_vault = false + + # Proxmox Credentials + proxmox_api_url = "https://10.100.0.2:8006/api2/json" + proxmox_api_token_id = "root@pam!terraform" + proxmox_api_token_secret = "dein-proxmox-token" + + # OPNsense Credentials + opnsense_uri = "https://10.100.0.1:4443" + opnsense_api_key = "dein-opnsense-key" + opnsense_api_secret = "dein-opnsense-secret" + + # VM User Config + ci_user = "ansible" + ci_password = "InitialPassword123!" # Wird später durch Vault ersetzt + ssh_public_key = "ssh-ed25519 AAAA..." + ``` + +2. **Terraform Initialisieren & Anwenden**: + Dies erstellt die VMs. Da Vault noch nicht existiert, nutzen wir die lokalen Credentials. + + ```bash + cd terraform + export VAULT_ADDR="http://127.0.0.1:8200" # Dummy Wert für Bootstrap (wird ignoriert) + terraform init + terraform apply -var-file="bootstrap.tfvars" + ``` + + ✅ **Ergebnis:** Alle VMs (inkl. `vm-docker-apps-301`) sind erstellt und laufen. + +--- + +## Phase 2: Vault Deployment & Setup + +Jetzt nutzen wir Ansible im **Push-Modus**, um Vault auf dem Zielserver zu installieren. + +1. **Ansible Voraussetzungen**: + + ```bash + cd ../infrastructure/ansible + ansible-galaxy install -r requirements.yml + ``` + +2. **Vault Deployen**: + Da Vault noch nicht läuft, wird Ansible Warnungen bei Secrets werfen (Permission Denied), aber das Deployment durchführen und den Container starten. + + ```bash + # Deploye auf alle Hosts im Inventory + ansible-playbook -i inventory.ini deploy.yml + ``` + +3. **Vault Initialisieren (Manuell)**: + Der Vault Container läuft nun. Wir müssen die erzeugten Keys abholen. + + * Hole das **Root Token** vom Server: + ```bash + ssh -i ~/.ssh/id_ed25519_ansible_prod ansible@10.100.30.11 "sudo cat /opt/vault/file/init_keys.json" + ``` + *(Hinweis: Die Datei `init_keys.json` enthält auch die Unseal-Keys. Speichere diese sicher ab!)* + + * Kopiere das Root Token (`root_token` aus dem JSON). + +4. **Vault Befüllen (Automatisch)**: + Führe das Helper-Skript aus, um die Secrets aus `bootstrap.tfvars` automatisch in Vault zu importieren. + + ```bash + cd ../.. # Zurück ins Repo-Root + ./setup_vault_secrets.sh + ``` + * Du wirst nach dem Root-Token gefragt. + * Das Skript importiert die Secrets. + * Es fragt, ob `bootstrap.tfvars` gelöscht werden soll (Ja). + * Es fragt, ob das **Root-Token** aus der Datei auf dem Server gelöscht werden soll (Ja, empfohlen für Security). + +--- + +## Phase 3: Production Mode & GitOps + +Ab jetzt ist die Infrastruktur "Self-Contained". Terraform und Ansible holen sich alle Zugangsdaten sicher aus dem Vault. + +### Terraform (Manuell bei Bedarf) +Änderungen an der Hardware (neue VMs, CPU/RAM) machst du weiterhin von deinem PC aus. + +```bash +cd terraform +export VAULT_ADDR='https://10.100.30.11:8200' +export VAULT_TOKEN='' +export VAULT_CACERT=../vault-ca.crt + +terraform plan # Sollte "No changes" zeigen +``` + +### GitOps Workflow (Automatisch) + +Die Server aktualisieren ihre Apps selbstständig (**Pull-Prinzip**). + +1. **Aktivierung (Einmalig):** + Das Playbook `deploy.yml` (aus Phase 2) hat bereits einen Systemd-Timer (`gitops-sync.timer`) auf allen Nodes installiert. + Dieser führt alle 5 Minuten `ansible-pull` aus. + +2. **Workflow:** + * Du machst Änderungen am Code (z.B. neue App in `apps/` oder Änderung in `deployments/`). + * Du pushst den **gesamten Code** in dein Git-Repo. + * Innerhalb von 5 Minuten ziehen sich die Server den neuen Stand. + * Sie führen das lokale Playbook `infrastructure/ansible/pull_deploy.yml` aus. + * Dieses Playbook: + * Installiert neue Apps. + * Updated existierende Apps. + * **Löscht Apps**, die aus der Deployment-Liste entfernt wurden (Pruning). + +3. **Voraussetzung:** + Die Variable `git_repo_url` in `infrastructure/ansible/deploy.yml` muss korrekt gesetzt sein. + +--- + +## Troubleshooting + +* **Fehler "Permission Denied" bei Ansible (Phase 2):** + Normal beim ersten Lauf, da Vault noch leer ist. Nach `setup_vault_secrets.sh` und einem erneuten `ansible-playbook` Lauf verschwinden sie. + +* **Apps werden nicht gelöscht:** + Die Pruning-Logik greift nur im **Pull-Modus** (also via Timer auf dem Server), nicht beim manuellen `ansible-playbook` von deinem PC aus. + +* **Terraform fragt nach Variablen:** + Prüfe, ob `VAULT_ADDR` und `VAULT_TOKEN` gesetzt sind und ob die Secrets im Vault unter den korrekten Pfaden (`secret/infrastructure/...`) liegen. diff --git a/infrastructure/ansible/deploy.yml b/infrastructure/ansible/deploy.yml new file mode 100644 index 0000000..6f6613a --- /dev/null +++ b/infrastructure/ansible/deploy.yml @@ -0,0 +1,55 @@ +--- +- name: Service-Centric GitOps Execution (Push Mode) + hosts: all + gather_facts: true + become: true # Docker braucht meist root/sudo + + vars: + # Basispfade (lokal auf dem Management-Controller) + repo_root: "{{ playbook_dir }}/.." + apps_catalog_path: "{{ repo_root }}/apps" + deployments_path: "{{ repo_root }}/deployments" + base_deploy_path: "/opt" + git_repo_url: "https://gitea.example.com/stabify/infra.git" # TODO: Anpassen! + + # Wir suchen die Definitionsdatei basierend auf FQDN + host_def_file_fqdn: "{{ deployments_path }}/{{ inventory_hostname }}.yml" + + roles: + # Stelle sicher, dass jeder Host Docker & Co hat + - common + + tasks: + # --- 1. Identifikation (Lokal prüfen, was der Host bekommen soll) --- + - name: "Suche Deployment-Definition für {{ inventory_hostname }}" + stat: + path: "{{ host_def_file_fqdn }}" + delegate_to: localhost + register: def_fqdn + + - name: "Warnung wenn unkonfiguriert" + debug: + msg: "Host {{ inventory_hostname }} hat keine Konfiguration in {{ deployments_path }}. Überspringe." + when: not def_fqdn.stat.exists + + - name: "Beende Host-Play wenn unkonfiguriert" + meta: end_host + when: not def_fqdn.stat.exists + + # --- 2. Lade Konfiguration (Lokal laden) --- + - name: "Lade Host-Konfiguration" + include_vars: + file: "{{ host_def_file_fqdn }}" + name: host_config + delegate_to: localhost + + - name: "Plan anzeigen" + debug: + msg: "Deploying auf {{ inventory_hostname }}: {{ host_config.apps }}" + + # --- 3. Ausführung (Remote auf den VMs) --- + - name: "Deploy Apps Loop" + include_tasks: deploy_logic_push.yml + loop: "{{ host_config.apps }}" + loop_control: + loop_var: app_name diff --git a/infrastructure/ansible/deploy_logic_pull.yml b/infrastructure/ansible/deploy_logic_pull.yml new file mode 100644 index 0000000..6efd527 --- /dev/null +++ b/infrastructure/ansible/deploy_logic_pull.yml @@ -0,0 +1,67 @@ +--- +# PULL Logic (läuft lokal auf dem Server) + +# 1. Validierung +- name: "Prüfe App im Katalog" + stat: + path: "{{ apps_catalog_path }}/{{ app_name }}" + register: catalog_entry + +- name: "Skip if missing" + debug: + msg: "App {{ app_name }} nicht gefunden." + when: not catalog_entry.stat.exists + +# 2. Setup +- name: "Setze Pfade" + set_fact: + source_dir: "{{ apps_catalog_path }}/{{ app_name }}" + target_dir: "{{ base_deploy_path }}/{{ app_name }}" + when: catalog_entry.stat.exists + +- name: "Erstelle Zielverzeichnis" + file: + path: "{{ target_dir }}" + state: directory + mode: '0755' + when: catalog_entry.stat.exists + +# 3. Secrets (Vault) +# Im Pull-Mode brauchen wir ein Token. Wir lesen es aus /root/.vault-token oder ENV +- name: "Lade Secrets (Lokal)" + set_fact: + app_secrets: "{{ lookup('community.hashi_vault.vault_kv2_get', 'apps/' + app_name, engine_mount_point='secret', url=vault_addr, token_path='/root/.vault-token') | default({}) }}" + ignore_errors: true + when: catalog_entry.stat.exists + +- name: "Erstelle .env" + copy: + dest: "{{ target_dir }}/.env" + content: | + {% for key, value in app_secrets.items() %} + {{ key }}={{ value }} + {% endfor %} + mode: '0600' + when: catalog_entry.stat.exists and app_secrets is defined and app_secrets | length > 0 + +# 4. Sync Files (Local Copy) +- name: "Sync Files" + copy: + src: "{{ source_dir }}/" + dest: "{{ target_dir }}/" + mode: '0644' + directory_mode: '0755' + when: catalog_entry.stat.exists + +# 5. Docker Compose +- name: "Docker Compose Up" + community.docker.docker_compose_v2: + project_src: "{{ target_dir }}" + state: present + pull: missing + build: always + remove_orphans: true + environment: + PATH: "/usr/bin:/usr/local/bin:/snap/bin:{{ ansible_env.PATH }}" + when: catalog_entry.stat.exists + diff --git a/infrastructure/ansible/deploy_logic_push.yml b/infrastructure/ansible/deploy_logic_push.yml new file mode 100644 index 0000000..48648b6 --- /dev/null +++ b/infrastructure/ansible/deploy_logic_push.yml @@ -0,0 +1,75 @@ +--- +# Push-Logik: Wir kopieren von Localhost -> Remote Host + +# 1. Validierung (Lokal) +- name: "Prüfe ob App '{{ app_name }}' im Katalog existiert (Lokal)" + stat: + path: "{{ apps_catalog_path }}/{{ app_name }}" + delegate_to: localhost + register: catalog_entry + +- name: "Fehler: App fehlt im Katalog" + fail: + msg: "App '{{ app_name }}' nicht gefunden in {{ apps_catalog_path }}" + when: not catalog_entry.stat.exists + +# 2. Setup Pfade (Remote) +- name: "Setze Zielpfad" + set_fact: + source_dir: "{{ apps_catalog_path }}/{{ app_name }}" + target_dir: "{{ base_deploy_path }}/{{ app_name }}" + +- name: "Erstelle Zielverzeichnis auf Remote" + file: + path: "{{ target_dir }}" + state: directory + mode: '0755' + +# 3. Secrets aus Vault (Lokal lookup, Remote copy) +- name: "Lade Secrets aus Vault (Lokal lookup)" + set_fact: + app_secrets: "{{ lookup('community.hashi_vault.vault_kv2_get', 'apps/' + app_name, engine_mount_point='secret', url=lookup('env', 'VAULT_ADDR') | default('https://10.100.30.11:8200'), token=lookup('env', 'VAULT_TOKEN')) | default({}) }}" + delegate_to: localhost + ignore_errors: true + +- name: "Setze app_secrets default wenn leer" + set_fact: + app_secrets: {} + when: app_secrets is undefined + + +- name: "Erstelle .env Datei auf Remote" + copy: + dest: "{{ target_dir }}/.env" + content: | + {% for key, value in app_secrets.items() %} + {{ key }}={{ value }} + {% endfor %} + mode: '0600' + when: app_secrets | length > 0 + +# 4. Sync Dateien (Lokal -> Remote) +# Hinweis: 'copy' Modul unterstützt kein 'exclude'. Für Excludes brauchen wir 'synchronize' (rsync) +# oder wir kopieren alles und ignorieren .env Konflikte (da copy sowieso überschreibt) +- name: "Synchronisiere App-Dateien (Push)" + copy: + src: "{{ source_dir }}/" + dest: "{{ target_dir }}/" + mode: '0644' + directory_mode: '0755' + # .env im Source wird überschrieben falls existent + + +# 5. Docker Compose Deployment (Remote) +- name: "Deploy {{ app_name }} mit Docker Compose" + community.docker.docker_compose_v2: + project_src: "{{ target_dir }}" + state: present + pull: missing + build: always + remove_orphans: true + environment: + PATH: "/usr/bin:/usr/local/bin:/snap/bin:{{ ansible_env.PATH }}" + register: compose_result + + diff --git a/infrastructure/ansible/inventory.ini b/infrastructure/ansible/inventory.ini new file mode 100644 index 0000000..81dcf9a --- /dev/null +++ b/infrastructure/ansible/inventory.ini @@ -0,0 +1,14 @@ +[docker_hosts] +vm-docker-apps-301.stabify.de ansible_host=10.100.30.11 +vm-docker-traefik-302.stabify.de ansible_host=10.100.30.12 +# vm-docker-mailcow-300.stabify.de ansible_host=10.100.30.10 + +[k3s_hosts] +# vm-k3s-master-400.stabify.de ansible_host=10.100.40.10 +# ... + +[all:vars] +ansible_user=ansible +ansible_ssh_common_args='-o StrictHostKeyChecking=no' +ansible_ssh_private_key_file=~/.ssh/id_ed25519_ansible_prod + diff --git a/infrastructure/ansible/inventory_local.ini b/infrastructure/ansible/inventory_local.ini new file mode 100644 index 0000000..e6c4fec --- /dev/null +++ b/infrastructure/ansible/inventory_local.ini @@ -0,0 +1,3 @@ +[local] +localhost ansible_connection=local ansible_python_interpreter=/usr/bin/python3 + diff --git a/infrastructure/ansible/prune_logic.yml b/infrastructure/ansible/prune_logic.yml new file mode 100644 index 0000000..9874f4f --- /dev/null +++ b/infrastructure/ansible/prune_logic.yml @@ -0,0 +1,22 @@ +--- +# Pruning Logic: Entfernt Apps, die nicht mehr gewünscht sind + +- name: "Prüfe auf docker-compose.yml in {{ base_deploy_path }}/{{ app_name_to_remove }}" + stat: + path: "{{ base_deploy_path }}/{{ app_name_to_remove }}/docker-compose.yml" + register: compose_file + +- name: "Stoppe und entferne Container für {{ app_name_to_remove }}" + community.docker.docker_compose_v2: + project_src: "{{ base_deploy_path }}/{{ app_name_to_remove }}" + state: absent + remove_orphans: true + environment: + PATH: "/usr/bin:/usr/local/bin:/snap/bin:{{ ansible_env.PATH }}" + when: compose_file.stat.exists + +- name: "Lösche App-Verzeichnis {{ base_deploy_path }}/{{ app_name_to_remove }}" + file: + path: "{{ base_deploy_path }}/{{ app_name_to_remove }}" + state: absent + diff --git a/infrastructure/ansible/pull_deploy.yml b/infrastructure/ansible/pull_deploy.yml new file mode 100644 index 0000000..5576cb2 --- /dev/null +++ b/infrastructure/ansible/pull_deploy.yml @@ -0,0 +1,80 @@ +--- +- name: "GitOps Execution (Local Pull Mode)" + hosts: localhost + connection: local + gather_facts: true + become: true + + vars: + # Pfade sind jetzt lokal auf dem Server + repo_root: "{{ playbook_dir }}/.." + apps_catalog_path: "{{ repo_root }}/apps" + deployments_path: "{{ repo_root }}/deployments" + base_deploy_path: "/opt" + + # Vault Adresse für lokalen Zugriff + vault_addr: "https://10.100.30.11:8200" + + tasks: + # 1. Identifikation + - name: "Bestimme Hostname (für Config Lookup)" + set_fact: + # ansible-pull läuft lokal, daher nehmen wir ansible_fqdn oder hostname + target_hostname: "{{ ansible_fqdn }}" + + - name: "Suche Deployment-Definition" + stat: + path: "{{ deployments_path }}/{{ target_hostname }}.yml" + register: def_file + + - name: "Abbruch wenn keine Config" + fail: + msg: "Keine Deployment-Config für {{ target_hostname }} gefunden." + when: not def_file.stat.exists + + # 2. Lade Config (SOLL-Zustand) + - name: "Lade Host-Konfiguration" + include_vars: + file: "{{ deployments_path }}/{{ target_hostname }}.yml" + name: host_config + + - name: "Definiere Soll-Apps" + set_fact: + wanted_apps: "{{ host_config.apps }}" + + # 3. Ermittle IST-Zustand + - name: "Finde installierte Apps in {{ base_deploy_path }}" + find: + paths: "{{ base_deploy_path }}" + file_type: directory + recurse: false + register: installed_dirs + + - name: "Filtere nicht-App Verzeichnisse (z.B. vault)" + set_fact: + # Wir nehmen an, dass alles in /opt eine App ist, außer explizite Ausnahmen + # Hier filtern wir nur Verzeichnisse, die Docker Compose Files haben könnten + installed_apps: "{{ installed_dirs.files | map(attribute='path') | map('basename') | list }}" + + # 4. Bereinigung (Pruning) + - name: "Ermittle zu löschende Apps" + set_fact: + # Apps die installiert sind, aber nicht in wanted_apps stehen + # ACHTUNG: 'vault' sollte ggf. geschützt werden, wenn es manuell läuft? + # Da wir Vault aber auch via GitOps managen (in der Liste), ist das ok. + apps_to_remove: "{{ installed_apps | difference(wanted_apps) }}" + + - name: "Pruning Loop" + include_tasks: prune_logic.yml + loop: "{{ apps_to_remove }}" + loop_control: + loop_var: app_name_to_remove + # Sicherheitshalber: Lösche nichts, was 'vault' heißt, falls Config kaputt ist + when: app_name_to_remove != 'vault' + + # 5. Deploy Apps (Update/Install) + - name: "Deploy Apps Loop" + include_tasks: deploy_logic_pull.yml + loop: "{{ wanted_apps }}" + loop_control: + loop_var: app_name diff --git a/infrastructure/ansible/requirements.yml b/infrastructure/ansible/requirements.yml new file mode 100644 index 0000000..4004f1a --- /dev/null +++ b/infrastructure/ansible/requirements.yml @@ -0,0 +1,7 @@ +--- +collections: + - name: community.docker + version: 3.10.0 + - name: community.hashi_vault + version: 6.0.0 + diff --git a/infrastructure/ansible/roles/common/handlers/main.yml b/infrastructure/ansible/roles/common/handlers/main.yml new file mode 100644 index 0000000..7f73010 --- /dev/null +++ b/infrastructure/ansible/roles/common/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload Systemd + systemd: + daemon_reload: true + diff --git a/infrastructure/ansible/roles/common/tasks/gitops.yml b/infrastructure/ansible/roles/common/tasks/gitops.yml new file mode 100644 index 0000000..89a935d --- /dev/null +++ b/infrastructure/ansible/roles/common/tasks/gitops.yml @@ -0,0 +1,20 @@ +- name: "Deploy GitOps Service Unit" + template: + src: gitops-sync.service.j2 + dest: /etc/systemd/system/gitops-sync.service + mode: '0644' + notify: Reload Systemd + +- name: "Deploy GitOps Timer Unit" + template: + src: gitops-sync.timer.j2 + dest: /etc/systemd/system/gitops-sync.timer + mode: '0644' + notify: Reload Systemd + +- name: "Aktiviere GitOps Timer" + systemd: + name: gitops-sync.timer + state: started + enabled: true + diff --git a/infrastructure/ansible/roles/common/tasks/main.yml b/infrastructure/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000..a535172 --- /dev/null +++ b/infrastructure/ansible/roles/common/tasks/main.yml @@ -0,0 +1,49 @@ +--- +- name: "Installiere Basispakete" + apt: + name: + - curl + - wget + - git + - htop + - vim + - net-tools + - dnsutils + - ca-certificates + - gnupg + - lsb-release + state: present + update_cache: true + +- name: "Installiere Ansible & Git für GitOps (Pull-Mode)" + apt: + name: + - ansible + - git + - python3-hvac # Für Vault + state: present + +- name: "Installiere Docker (Convenience Script)" + # Nutzung des offiziellen Docker Install Scripts ist oft robuster als Einzelpakete + # Alternativ: Manuelles Repo-Adding (sauberer, aber mehr Code) + shell: "curl -fsSL https://get.docker.com | sh" + args: + creates: /usr/bin/docker + +- name: "Füge User zur Docker Gruppe hinzu" + user: + name: "{{ ansible_user }}" + groups: docker + append: true + +# Docker Service sicherstellen +- name: "Starte Docker Service" + service: + name: docker + state: started + enabled: true + +# GitOps Setup +- import_tasks: gitops.yml + + diff --git a/infrastructure/ansible/roles/common/templates/gitops-sync.service.j2 b/infrastructure/ansible/roles/common/templates/gitops-sync.service.j2 new file mode 100644 index 0000000..24b1d77 --- /dev/null +++ b/infrastructure/ansible/roles/common/templates/gitops-sync.service.j2 @@ -0,0 +1,20 @@ +[Unit] +Description=Ansible Pull GitOps Sync +Documentation=https://docs.ansible.com/ansible/latest/cli/ansible-pull.html +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=root +# Wir nutzen ansible-pull um das Repo zu holen und das lokale Playbook auszuführen +# -U: URL des Repos +# -d: Checkout Verzeichnis +# -i: Inventory (hier localhost) +# pull_deploy.yml: Das Playbook im Repo +ExecStart=/usr/bin/ansible-pull -U {{ git_repo_url }} -d /opt/stabify-infra -i infrastructure/ansible/inventory_local.ini infrastructure/ansible/pull_deploy.yml +timeoutStartSec=600 + +[Install] +WantedBy=multi-user.target + diff --git a/infrastructure/ansible/roles/common/templates/gitops-sync.timer.j2 b/infrastructure/ansible/roles/common/templates/gitops-sync.timer.j2 new file mode 100644 index 0000000..8b98b82 --- /dev/null +++ b/infrastructure/ansible/roles/common/templates/gitops-sync.timer.j2 @@ -0,0 +1,12 @@ +[Unit] +Description=Trigger Ansible Pull GitOps Sync every 5 minutes +After=network-online.target + +[Timer] +OnBootSec=5min +OnUnitActiveSec=5min +RandomizedDelaySec=60 + +[Install] +WantedBy=timers.target + diff --git a/infrastructure/apps/traefik-edge/.gitignore b/infrastructure/apps/traefik-edge/.gitignore new file mode 100644 index 0000000..69a494d --- /dev/null +++ b/infrastructure/apps/traefik-edge/.gitignore @@ -0,0 +1,2 @@ +.env +certs/ \ No newline at end of file diff --git a/infrastructure/apps/traefik-edge/config/dynamic/00-middlewares.yaml b/infrastructure/apps/traefik-edge/config/dynamic/00-middlewares.yaml new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/apps/traefik-edge/config/dynamic/10-k3s.yaml b/infrastructure/apps/traefik-edge/config/dynamic/10-k3s.yaml new file mode 100644 index 0000000..f34105d --- /dev/null +++ b/infrastructure/apps/traefik-edge/config/dynamic/10-k3s.yaml @@ -0,0 +1 @@ +#Testcomment \ No newline at end of file diff --git a/infrastructure/apps/traefik-edge/config/dynamic/20-legacy-vm.yaml b/infrastructure/apps/traefik-edge/config/dynamic/20-legacy-vm.yaml new file mode 100644 index 0000000..2ee64e0 --- /dev/null +++ b/infrastructure/apps/traefik-edge/config/dynamic/20-legacy-vm.yaml @@ -0,0 +1,18 @@ +http: + routers: + # Route für Apps auf VM 301 + to-apps-vm: + rule: HostRegexp(`^[a-z0-9-]+\.apps\.stabify\.de$`) + service: apps-vm-service + entryPoints: [ websecure ] + tls: + certResolver: le + domains: + - main: "*.apps.stabify.de" + + services: + apps-vm-service: + loadBalancer: + servers: + - url: "http://vm-docker-apps-301.stabify.de:80" + passHostHeader: true \ No newline at end of file diff --git a/infrastructure/apps/traefik-edge/config/dynamic/90-edge-system.yaml b/infrastructure/apps/traefik-edge/config/dynamic/90-edge-system.yaml new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/apps/traefik-edge/config/traefik.yml b/infrastructure/apps/traefik-edge/config/traefik.yml new file mode 100644 index 0000000..d4ae5ec --- /dev/null +++ b/infrastructure/apps/traefik-edge/config/traefik.yml @@ -0,0 +1,42 @@ +api: + dashboard: false + +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + + websecure: + address: ":443" + http: + tls: + certResolver: le + domains: + - main: "stabify.de" + sans: + - "*.stabify.de" + - "*.k3s.stabify.de" + - "*.sys.stabify.de" + - "*.apps.stabify.de" + +providers: + docker: + endpoint: "unix:///var/run/docker.sock" + exposedByDefault: false + file: + directory: "/etc/traefik/dynamic" + watch: true + +certificatesResolvers: + le: + acme: + email: acme@infrastructure.stabify.de + storage: /certs/acme.json + caServer: https://acme-v02.api.letsencrypt.org/directory + dnsChallenge: + provider: cloudflare + delayBeforeCheck: 10 \ No newline at end of file diff --git a/infrastructure/apps/traefik-edge/docker-compose.yml b/infrastructure/apps/traefik-edge/docker-compose.yml new file mode 100644 index 0000000..cf80c72 --- /dev/null +++ b/infrastructure/apps/traefik-edge/docker-compose.yml @@ -0,0 +1,30 @@ +--- +services: + traefik: + image: traefik:v3.6 + container_name: traefik-edge + restart: unless-stopped + security_opt: + - no-new-privileges:true + environment: + - TZ=Europe/Berlin + - CF_ZONE_API_TOKEN=${CF_ZONE_API_TOKEN} + - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN} + command: + # --- DEBUGGING AKTIVIEREN --- + - "--log.level=DEBUG" # Setzt das Log-Level auf DEBUG (Fehlersuche) + - "--accesslog=true" + ports: + - "80:80" + - "443:443" + volumes: + - /etc/localtime:/etc/localtime:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./config/traefik.yml:/etc/traefik/traefik.yml:ro + - ./config/dynamic:/etc/traefik/dynamic:ro + - ./certs:/certs + networks: + - proxy +networks: + proxy: + name: proxy-edge \ No newline at end of file diff --git a/infrastructure/apps/traefik-sub/docker-compose.yml b/infrastructure/apps/traefik-sub/docker-compose.yml new file mode 100644 index 0000000..928f299 --- /dev/null +++ b/infrastructure/apps/traefik-sub/docker-compose.yml @@ -0,0 +1,23 @@ +--- +services: + traefik: + image: traefik:v3.6 + container_name: traefik-sub + restart: unless-stopped + environment: + - TZ=Europe/Berlin + command: + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + ports: + - "80:80" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - proxy + +networks: + proxy: + name: proxy-sub + external: false \ No newline at end of file diff --git a/infrastructure/apps/vault/Dockerfile b/infrastructure/apps/vault/Dockerfile new file mode 100644 index 0000000..17bddbc --- /dev/null +++ b/infrastructure/apps/vault/Dockerfile @@ -0,0 +1,12 @@ +FROM hashicorp/vault:1.15 + +# Install dependencies for automation script +RUN apk add --no-cache openssl jq curl bash ca-certificates + +# Copy entrypoint script +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Use our script as entrypoint +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + diff --git a/infrastructure/apps/vault/README_VAULT.md b/infrastructure/apps/vault/README_VAULT.md new file mode 100644 index 0000000..3cb27d7 --- /dev/null +++ b/infrastructure/apps/vault/README_VAULT.md @@ -0,0 +1,81 @@ +# Vault Operations Manual (Automated) + +Dieses Dokument beschreibt den Betrieb von HashiCorp Vault innerhalb der Stabify Infrastruktur. +Vault läuft als Docker-Container auf der VM `vm-docker-apps-301.stabify.de` (IP: `10.100.30.11`). + +## Automatisiertes Setup + +Dieser Service nutzt ein **Custom Entrypoint Script**, welches folgende Schritte automatisiert: +1. **Zertifikate**: Generiert CA & Server-Zertifikate beim Start, falls diese fehlen. +2. **Initialisierung**: Initialisiert Vault automatisch beim ersten Start. +3. **Auto-Unseal**: Speichert die Keys lokal (`file/init_keys.json`) und nutzt sie zum automatischen Entsperren beim Boot. + +**⚠️ SECURITY WARNING:** +Die Unseal-Keys werden im Klartext unter `/opt/vault/file/init_keys.json` gespeichert. +Dies dient dem komfortablen "Set-and-Forget" Betrieb im Homelab. In Hochsicherheitsumgebungen sollte diese Datei nach dem initialen Setup gelöscht und die Keys an einem sicheren Ort (Passwort Manager) verwahrt werden. + +## Inbetriebnahme + +### 1. Deployment (via Ansible) +Da der Service Teil der `vm-docker-apps-301` Deployment-Definition ist, wird er automatisch gestartet, sobald das Ansible-Playbook läuft. + +### 2. Zugriff erhalten +Nach dem Start liegen die generierten Daten auf dem Server. + +1. **CA Zertifikat holen** (damit dein Browser/Client vertraut): + ```bash + scp ansible@10.100.30.11:/opt/vault/certs/ca.crt ./ + # Importiere ca.crt in deinen Truststore / Schlüsselbund + ``` + +2. **Root Token holen** (für Admin-Zugriff): + ```bash + ssh ansible@10.100.30.11 "cat /opt/vault/file/init_keys.json" | jq -r .root_token + ``` + +3. **Login**: + ```bash + export VAULT_ADDR='https://10.100.30.11:8200' + export VAULT_CACERT=./ca.crt + vault login + ``` + +## Secrets anlegen (Einmalig) + +Aktiviere die KV v2 Engine und lege die benötigten Secrets an. + +```bash +# Engine aktivieren +vault secrets enable -path=secret kv-v2 + +# 1. Proxmox Credentials +vault kv put secret/infrastructure/proxmox \ + api_token_id="root@pam!terraform" \ + api_token_secret="dein-secret-token" + +# 2. OPNsense Credentials +vault kv put secret/infrastructure/opnsense \ + api_key="dein-api-key" \ + api_secret="dein-api-secret" + +# 3. VM User Credentials +vault kv put secret/infrastructure/vm-credentials \ + ci_user="ansible" \ + ci_password="super-secure-password" \ + ssh_public_key="ssh-ed25519 AAAA..." +``` + +## Troubleshooting + +**Logs prüfen:** +```bash +ssh ansible@10.100.30.11 "docker logs vault-prod" +``` + +**Zertifikate neu generieren:** +Lösche einfach den Ordner `certs` auf dem Server und starte den Container neu. +```bash +rm -rf /opt/vault/certs/* +docker compose restart vault +``` +**Achtung:** Danach musst du das neue `ca.crt` wieder auf deine Clients verteilen. diff --git a/infrastructure/apps/vault/config/vault.hcl b/infrastructure/apps/vault/config/vault.hcl new file mode 100644 index 0000000..0b44eb0 --- /dev/null +++ b/infrastructure/apps/vault/config/vault.hcl @@ -0,0 +1,20 @@ +storage "raft" { + path = "/vault/file" + node_id = "node1" +} + +listener "tcp" { + address = "0.0.0.0:8200" + tls_cert_file = "/vault/config/certs/vault.crt" + tls_key_file = "/vault/config/certs/vault.key" + tls_disable = 0 +} + +api_addr = "https://10.100.30.11:8200" +cluster_addr = "https://10.100.30.11:8201" +ui = true + +# Production hardening +disable_mlock = true +max_lease_ttl = "768h" +default_lease_ttl = "168h" diff --git a/infrastructure/apps/vault/docker-compose.yml b/infrastructure/apps/vault/docker-compose.yml new file mode 100644 index 0000000..469ac2f --- /dev/null +++ b/infrastructure/apps/vault/docker-compose.yml @@ -0,0 +1,25 @@ +services: + vault: + build: . + image: stabify/vault-custom:latest + container_name: vault-prod + restart: unless-stopped + ports: + - "8200:8200" + environment: + VAULT_ADDR: 'https://127.0.0.1:8200' + VAULT_API_ADDR: 'https://127.0.0.1:8200' + volumes: + - ./config:/vault/config + - ./file:/vault/file + - ./logs:/vault/logs + # Mount certs directory. + - ./certs:/vault/config/certs + cap_add: + - IPC_LOCK + networks: + - internal + +networks: + internal: + name: vault-net diff --git a/infrastructure/apps/vault/entrypoint.sh b/infrastructure/apps/vault/entrypoint.sh new file mode 100644 index 0000000..b5e78c4 --- /dev/null +++ b/infrastructure/apps/vault/entrypoint.sh @@ -0,0 +1,99 @@ +#!/bin/sh +set -e + +# --- 1. Auto-Generate Certificates --- +CERTS_DIR="/vault/config/certs" +if [ ! -f "$CERTS_DIR/vault.crt" ] || [ ! -f "$CERTS_DIR/vault.key" ]; then + echo "[ENTRYPOINT] Certificates missing. Generating self-signed certs..." + mkdir -p "$CERTS_DIR" + + # Create CA + openssl genrsa -out "$CERTS_DIR/ca.key" 4096 + openssl req -new -x509 -days 3650 -key "$CERTS_DIR/ca.key" -out "$CERTS_DIR/ca.crt" \ + -subj "/C=DE/ST=Berlin/L=Berlin/O=Stabify/OU=IT/CN=StabifyRootCA" + + # Create Server Key/CSR + openssl genrsa -out "$CERTS_DIR/vault.key" 4096 + openssl req -new -key "$CERTS_DIR/vault.key" -out "$CERTS_DIR/vault.csr" \ + -subj "/C=DE/ST=Berlin/L=Berlin/O=Stabify/OU=IT/CN=vault.stabify.de" + + # Config for SANs + cat > "$CERTS_DIR/v3.ext" << EOF +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +subjectAltName = @alt_names + +[alt_names] +DNS.1 = vault.stabify.de +DNS.2 = vm-docker-apps-301.stabify.de +DNS.3 = localhost +IP.1 = 127.0.0.1 +IP.2 = 10.100.30.11 +EOF + + # Sign Cert + openssl x509 -req -in "$CERTS_DIR/vault.csr" \ + -CA "$CERTS_DIR/ca.crt" -CAkey "$CERTS_DIR/ca.key" -CAcreateserial \ + -out "$CERTS_DIR/vault.crt" -days 3650 -sha256 -extfile "$CERTS_DIR/v3.ext" + + chmod 644 "$CERTS_DIR/vault.crt" "$CERTS_DIR/ca.crt" + chmod 600 "$CERTS_DIR/vault.key" "$CERTS_DIR/ca.key" + + echo "[ENTRYPOINT] Certificates generated successfully." +fi + +# Trust our own CA inside the container (for local curl/vault calls) +cp "$CERTS_DIR/ca.crt" /usr/local/share/ca-certificates/stabify-ca.crt +update-ca-certificates + +# --- 2. Start Vault in Background --- +echo "[ENTRYPOINT] Starting Vault server..." +vault server -config=/vault/config/vault.hcl & +VAULT_PID=$! + +# Wait for Vault to be ready (it will be sealed initially) +echo "[ENTRYPOINT] Waiting for Vault API..." +until nc -z 127.0.0.1 8200; do + sleep 1 +done +sleep 2 + +# --- 3. Auto-Init --- +export VAULT_ADDR='https://127.0.0.1:8200' +export VAULT_SKIP_VERIFY=true # We trust localhost + +KEYS_FILE="/vault/file/init_keys.json" + +if ! vault status | grep -q "Initialized.*true"; then + echo "[ENTRYPOINT] Vault is not initialized. Initializing..." + vault operator init -format=json > "$KEYS_FILE" + chmod 600 "$KEYS_FILE" + echo "[ENTRYPOINT] Vault initialized. Keys saved to $KEYS_FILE" + echo "!!! WARNING: Unseal keys are stored in $KEYS_FILE. Secure this file or delete it after noting the keys !!!" +fi + +# --- 4. Auto-Unseal --- +if [ -f "$KEYS_FILE" ]; then + echo "[ENTRYPOINT] Found keys file. Attempting auto-unseal..." + # Read first 3 keys and unseal + KEY1=$(jq -r ".unseal_keys_b64[0]" "$KEYS_FILE") + KEY2=$(jq -r ".unseal_keys_b64[1]" "$KEYS_FILE") + KEY3=$(jq -r ".unseal_keys_b64[2]" "$KEYS_FILE") + + vault operator unseal "$KEY1" > /dev/null + vault operator unseal "$KEY2" > /dev/null + vault operator unseal "$KEY3" > /dev/null + + if vault status | grep -q "Sealed.*false"; then + echo "[ENTRYPOINT] Vault successfully unsealed!" + else + echo "[ENTRYPOINT] Failed to unseal Vault." + fi +else + echo "[ENTRYPOINT] No keys file found. Manual unseal required." +fi + +# --- 5. Wait for Vault Process --- +wait $VAULT_PID + diff --git a/infrastructure/apps/vault/vars.yml b/infrastructure/apps/vault/vars.yml new file mode 100644 index 0000000..db97c2c --- /dev/null +++ b/infrastructure/apps/vault/vars.yml @@ -0,0 +1,8 @@ +app_name: "vault" + +vault_config: + ui: true + listener_address: "0.0.0.0:8200" + api_addr: "https://10.100.30.11:8200" + cluster_addr: "https://10.100.30.11:8201" + diff --git a/infrastructure/apps/whoami/docker-compose.yml b/infrastructure/apps/whoami/docker-compose.yml new file mode 100644 index 0000000..c34a966 --- /dev/null +++ b/infrastructure/apps/whoami/docker-compose.yml @@ -0,0 +1,16 @@ +--- +services: + whoami: + image: traefik/whoami + container_name: whoami + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.whoami.rule=Host(`whoami.apps.stabify.de`)" + - "traefik.http.routers.whoami.entrypoints=web" + networks: + - proxy-sub + +networks: + proxy-sub: + external: true \ No newline at end of file diff --git a/infrastructure/deployments/vm-docker-apps-301.stabify.de.yml b/infrastructure/deployments/vm-docker-apps-301.stabify.de.yml new file mode 100644 index 0000000..85b0513 --- /dev/null +++ b/infrastructure/deployments/vm-docker-apps-301.stabify.de.yml @@ -0,0 +1,7 @@ +apps: + - vault + - traefik-sub + - whoami + # Hier einfach weitere Apps aus dem Katalog hinzufügen: + # - nextcloud + # - monitoring \ No newline at end of file diff --git a/infrastructure/deployments/vm-docker-traefik-302.stabify.de.yml b/infrastructure/deployments/vm-docker-traefik-302.stabify.de.yml new file mode 100644 index 0000000..b751efb --- /dev/null +++ b/infrastructure/deployments/vm-docker-traefik-302.stabify.de.yml @@ -0,0 +1,2 @@ +apps: + - traefik-edge \ No newline at end of file diff --git a/setup_vault_secrets.sh b/setup_vault_secrets.sh new file mode 100755 index 0000000..d6f7c54 --- /dev/null +++ b/setup_vault_secrets.sh @@ -0,0 +1,119 @@ +#!/bin/bash +set -e + +# Pfad zur Bootstrap-Datei +BOOTSTRAP_VARS="terraform/bootstrap.tfvars" +VAULT_CA_LOCAL="./vault-ca.crt" + +# Check if bootstrap vars exist +if [ ! -f "$BOOTSTRAP_VARS" ]; then + echo "Fehler: $BOOTSTRAP_VARS nicht gefunden." + echo "Bitte stelle sicher, dass du im Root des Repos bist und die Datei existiert." + exit 1 +fi + +# Check for Vault CA +if [ ! -f "$VAULT_CA_LOCAL" ]; then + echo "Lade CA Zertifikat vom Vault Server..." + scp -i ~/.ssh/id_ed25519_ansible_prod ansible@10.100.30.11:/opt/vault/certs/ca.crt "$VAULT_CA_LOCAL" +fi + +# Get Root Token from user +read -sp "Bitte gib das Vault Root Token ein (aus init_keys.json): " VAULT_ROOT_TOKEN +echo "" + +if [ -z "$VAULT_ROOT_TOKEN" ]; then + echo "Token darf nicht leer sein." + exit 1 +fi + +# Setup Vault Environment +export VAULT_ADDR='https://10.100.30.11:8200' +export VAULT_TOKEN="$VAULT_ROOT_TOKEN" +export VAULT_CACERT="$VAULT_CA_LOCAL" + +echo "Prüfe Vault Status..." +vault status > /dev/null + +echo "Aktiviere KV v2 Engine..." +vault secrets enable -path=secret kv-v2 || echo "Engine existiert bereits (ignoriere Fehler)." + +# Helper function to extract var from tfvars (simple grep/cut, assumes standard formatting) +get_var() { + grep "^$1" "$BOOTSTRAP_VARS" | cut -d'=' -f2- | tr -d ' "' | sed 's/#.*//' | xargs +} + +echo "Lese Secrets aus $BOOTSTRAP_VARS..." + +PM_TOKEN_ID=$(get_var "proxmox_api_token_id") +PM_TOKEN_SECRET=$(get_var "proxmox_api_token_secret") +OPN_KEY=$(get_var "opnsense_api_key") +OPN_SECRET=$(get_var "opnsense_api_secret") +OPN_URI=$(get_var "opnsense_uri") +CI_USER=$(get_var "ci_user") +CI_PASS=$(get_var "ci_password") +SSH_KEY=$(get_var "ssh_public_key") + +echo "Schreibe Secrets in Vault..." + +vault kv put secret/infrastructure/proxmox \ + api_token_id="$PM_TOKEN_ID" \ + api_token_secret="$PM_TOKEN_SECRET" + +vault kv put secret/infrastructure/opnsense \ + api_key="$OPN_KEY" \ + api_secret="$OPN_SECRET" \ + uri="$OPN_URI" + +vault kv put secret/infrastructure/vm-credentials \ + ci_user="$CI_USER" \ + ci_password="$CI_PASS" \ + ssh_public_key="$SSH_KEY" + +echo "✅ Alle Secrets erfolgreich importiert!" + +# --- Cleanup & Switch to Production --- +echo "" +echo "----------------------------------------------------------------" +echo "PHASE 3: CLEANUP & PRODUCTION SWITCH" +echo "----------------------------------------------------------------" +echo "Vault ist nun befüllt. Wir können nun die lokalen Secrets löschen" +echo "und Terraform auf den Production-Mode umstellen." +echo "" +read -p "Möchtest du '$BOOTSTRAP_VARS' jetzt löschen? (y/n) " -n 1 -r +echo "" +if [[ $REPLY =~ ^[Yy]$ ]]; then + rm "$BOOTSTRAP_VARS" + echo "🗑️ '$BOOTSTRAP_VARS' wurde gelöscht." + echo "ℹ️ Hinweis: Die Variable 'use_vault' in Terraform defaults auf 'true'," + echo " daher ist keine weitere Dateiänderung nötig." +else + echo "⚠️ Datei wurde NICHT gelöscht. Bitte denke daran, sie manuell zu entfernen," + echo " bevor du den Code ins Git pushst!" +fi + +echo "" +echo "----------------------------------------------------------------" +echo "SECURITY: CLEANUP REMOTE KEYS" +echo "----------------------------------------------------------------" +echo "Auf dem Vault-Server liegt die Datei '/opt/vault/file/init_keys.json'." +echo "Diese enthält das Root-Token und die Unseal-Keys im Klartext." +echo "Für maximale Sicherheit sollte diese Datei gelöscht werden (Achtung: Auto-Unseal geht dann nicht mehr!)" +echo "oder zumindest das Root-Token daraus entfernt werden." +echo "" +read -p "Soll das Root-Token jetzt remote aus der Datei entfernt werden (empfohlen)? (y/n) " -n 1 -r +echo "" +if [[ $REPLY =~ ^[Yy]$ ]]; then + # Wir nutzen jq um das root_token Feld zu löschen und überschreiben die Datei + ssh -i ~/.ssh/id_ed25519_ansible_prod ansible@10.100.30.11 "sudo jq 'del(.root_token)' /opt/vault/file/init_keys.json | sudo tee /opt/vault/file/init_keys.json.safe > /dev/null && sudo mv /opt/vault/file/init_keys.json.safe /opt/vault/file/init_keys.json && sudo chmod 600 /opt/vault/file/init_keys.json" + + if [ $? -eq 0 ]; then + echo "✅ Root-Token wurde aus der Remote-Datei entfernt." + echo " Die Unseal-Keys bleiben für den Auto-Unseal erhalten." + else + echo "❌ Fehler beim Bereinigen der Remote-Datei." + fi +fi + +echo "" +echo "🎉 Setup abgeschlossen! Du bist jetzt im Production Mode." diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..e3fdb4b --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,5 @@ +# Terraform +.terraform/ +.terraform.lock.hcl +terraform.tfstate* +terraform.tfvars diff --git a/terraform/data.tf b/terraform/data.tf new file mode 100644 index 0000000..80047f4 --- /dev/null +++ b/terraform/data.tf @@ -0,0 +1,16 @@ +# Expects secrets at specific paths in Vault KV v2 (mount point 'secret/') + +data "vault_generic_secret" "proxmox" { + count = var.use_vault ? 1 : 0 + path = "secret/infrastructure/proxmox" +} + +data "vault_generic_secret" "opnsense" { + count = var.use_vault ? 1 : 0 + path = "secret/infrastructure/opnsense" +} + +data "vault_generic_secret" "vm_creds" { + count = var.use_vault ? 1 : 0 + path = "secret/infrastructure/vm-credentials" +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..d5f14be --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,25 @@ +locals { + # SSH Public Key for Provisioning + ssh_key = var.use_vault ? data.vault_generic_secret.vm_creds[0].data["ssh_public_key"] : var.ssh_public_key + + # CI Credentials + ci_user = var.use_vault ? data.vault_generic_secret.vm_creds[0].data["ci_user"] : var.ci_user + ci_password = var.use_vault ? data.vault_generic_secret.vm_creds[0].data["ci_password"] : var.ci_password + + vms = { + # VLAN 30: Docker + "vm-docker-mailcow-300" = { id = 300, cores = 4, memory = 8192, vlan = 30, tags = "docker,mailcow", ip = "10.100.30.10", gw = "10.100.30.1" } + "vm-docker-apps-301" = { id = 301, cores = 2, memory = 4096, vlan = 30, tags = "docker,apps", ip = "10.100.30.11", gw = "10.100.30.1" } + "vm-docker-traefik-302" = { id = 302, cores = 1, memory = 2048, vlan = 30, tags = "docker,ingress", ip = "10.100.30.12", gw = "10.100.30.1" } + + # VLAN 40: K3s + "vm-k3s-master-400" = { id = 400, cores = 2, memory = 4096, vlan = 40, tags = "k3s,master", ip = "10.100.40.10", gw = "10.100.40.1" } + "vm-k3s-worker-401" = { id = 401, cores = 2, memory = 4096, vlan = 40, tags = "k3s,worker", ip = "10.100.40.11", gw = "10.100.40.1" } + "vm-k3s-worker-402" = { id = 402, cores = 2, memory = 4096, vlan = 40, tags = "k3s,worker", ip = "10.100.40.12", gw = "10.100.40.1" } + "vm-k3s-worker-403" = { id = 403, cores = 2, memory = 4096, vlan = 40, tags = "k3s,worker", ip = "10.100.40.13", gw = "10.100.40.1" } + + # VLAN 90: Bastion + "vm-bastion-900" = { id = 900, cores = 1, memory = 2048, vlan = 90, tags = "bastion", ip = "10.100.90.10", gw = "10.100.90.1" } + "vm-bastion-901" = { id = 901, cores = 1, memory = 2048, vlan = 90, tags = "bastion", ip = "10.100.90.11", gw = "10.100.90.1" } + } +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..5c1f6e2 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,79 @@ +resource "proxmox_vm_qemu" "vm_deployment" { + for_each = local.vms + + target_node = var.pm_node + + name = "${each.key}.stabify.de" + vmid = each.value.id + + description = "Managed by Terraform. VLAN: ${each.value.vlan} Role: ${each.value.tags} IP: ${each.value.ip}" + clone = var.template_name + full_clone = true + agent = 1 + + start_at_node_boot = true + define_connection_info = false + + cpu { + cores = each.value.cores + sockets = 1 + } + + memory = each.value.memory + balloon = 0 + scsihw = "virtio-scsi-pci" + boot = "order=scsi0;net0" + + serial { + id = 0 + type = "socket" + } + + disk { + slot = "scsi0" + size = "32G" + type = "disk" + storage = "local-lvm" + iothread = true + } + + disk { + slot = "ide2" + type = "cloudinit" + storage = "local-lvm" + } + + network { + id = 0 + model = "virtio" + bridge = "vmbr1" + tag = each.value.vlan + } + + os_type = "cloud-init" + + searchdomain = "stabify.de" + nameserver = each.value.gw + + ciuser = local.ci_user + cipassword = local.ci_password + sshkeys = local.ssh_key + + ipconfig0 = "ip=${each.value.ip}/24,gw=${each.value.gw}" + + tags = each.value.tags + + lifecycle { + ignore_changes = [ network ] + } +} + +resource "opnsense_unbound_host_override" "dns_entries" { + for_each = local.vms + + enabled = true + hostname = each.key + domain = "stabify.de" + description = "Managed by Terraform: ${each.value.tags}" + server = each.value.ip +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..668f4b8 --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,20 @@ +provider "vault" { + # Configuration via VAULT_ADDR and VAULT_TOKEN env vars +} + +provider "proxmox" { + pm_tls_insecure = true + pm_api_url = var.proxmox_api_url + + # Logic: If use_vault is true, verify vault data exists, otherwise use vars + pm_api_token_id = var.use_vault ? data.vault_generic_secret.proxmox[0].data["api_token_id"] : var.proxmox_api_token_id + pm_api_token_secret = var.use_vault ? data.vault_generic_secret.proxmox[0].data["api_token_secret"] : var.proxmox_api_token_secret +} + +provider "opnsense" { + uri = var.use_vault ? data.vault_generic_secret.opnsense[0].data["uri"] : var.opnsense_uri + allow_insecure = true + + api_key = var.use_vault ? data.vault_generic_secret.opnsense[0].data["api_key"] : var.opnsense_api_key + api_secret = var.use_vault ? data.vault_generic_secret.opnsense[0].data["api_secret"] : var.opnsense_api_secret +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..d3a7f95 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,67 @@ +variable "use_vault" { + type = bool + default = true + description = "Set to false to bypass Vault and use local variables (Bootstrap Mode)" +} + +variable "proxmox_api_token_id" { + type = string + sensitive = true + default = null +} + +variable "proxmox_api_token_secret" { + type = string + sensitive = true + default = null +} + +variable "opnsense_api_key" { + type = string + sensitive = true + default = null +} + +variable "opnsense_api_secret" { + type = string + sensitive = true + default = null +} + +variable "ci_user" { + type = string + default = null +} + +variable "ci_password" { + type = string + sensitive = true + default = null +} + +variable "ssh_public_key" { + type = string + default = null +} + +variable "proxmox_api_url" { + type = string + default = "https://10.100.0.2:8006/api2/json" +} + +variable "pm_node" { + type = string + default = "hzfsn-pve-01" +} + +variable "template_name" { + type = string + default = "ubuntu-2404-ci" + description = "Name des Cloud-Init Templates auf dem Node" +} + +variable "opnsense_uri" { + type = string + description = "URI to OPNsense API" + default = null +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..d3954a2 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.5.0" + + # Enterprise: Remote State Management (Placeholder) + # backend "s3" { + # bucket = "terraform-state" + # key = "prod/infrastructure.tfstate" + # region = "eu-central-1" + # } + + required_providers { + proxmox = { + source = "telmate/proxmox" + version = "3.0.2-rc07" # Pinned as requested + } + opnsense = { + source = "browningluke/opnsense" + version = "0.16.1" + } + vault = { + source = "hashicorp/vault" + version = "~> 3.24.0" + } + } +} + diff --git a/vault-ca.crt b/vault-ca.crt new file mode 100644 index 0000000..5b28b12 --- /dev/null +++ b/vault-ca.crt @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFrTCCA5WgAwIBAgIUMW5OEPxg8P8YijUOoJ2EDRMkkNswDQYJKoZIhvcNAQEL +BQAwZjELMAkGA1UEBhMCREUxDzANBgNVBAgMBkJlcmxpbjEPMA0GA1UEBwwGQmVy +bGluMRAwDgYDVQQKDAdTdGFiaWZ5MQswCQYDVQQLDAJJVDEWMBQGA1UEAwwNU3Rh +YmlmeVJvb3RDQTAeFw0yNjAxMDgxOTE3MTJaFw0zNjAxMDYxOTE3MTJaMGYxCzAJ +BgNVBAYTAkRFMQ8wDQYDVQQIDAZCZXJsaW4xDzANBgNVBAcMBkJlcmxpbjEQMA4G +A1UECgwHU3RhYmlmeTELMAkGA1UECwwCSVQxFjAUBgNVBAMMDVN0YWJpZnlSb290 +Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDYIY89KCT5JkvuA2Bd +sRB5Dwk9xm9PWILekJZaopHqWTrAARW7gJU0SvDmWb8lwiiS27bXA/doAKVSmccM +N+FkQ31LF3cREbTO87NH3Ldosn2YLZXM2cf9181ORuLbLJR/fEiNbY+iL8MhnwQH +GUbery3XK1LsU5zbpdjCth0zKbWZ0Gbi8SmhHvZDUJy4BAUVKYFqH2BVfiAPAZf6 +vBL0SQjaGc+9v6My6SurBQzAGyBtcaBoJ1tLR6S8PSEFDn6eQzPSZXaMJBN79wZM +WYenW1HZtKTGv8Xz3T9yzYoLuzE1VQejhPrURupfs0wcfGiIZ/iP421Klj3qg/YW +Vh2Wj4EHZLC4gV5/exUznmADEgvG6qUjV1eLkxyf0KIFzGYshxXVgrp3JCUtulMe +t52Op8yUxYgkHfCw5JpiYJ4j9dQ7pgApY89mr/tuFjlJw64oS9GKWh4l3X31m1Ss +NWESVP2zjqtE+89n8tqRBTc8HCIUnXzKy6PtbtLjYYHWWyi6UsXMW+Vq5jkGaiYZ +9NzVb3wJcOWyPQW5nLL4rWUu4E514Kx4+Rq4qsrqsucIDEbO72gWXp9X8qCUF+TB +QL4n7g+Bz6PNWOFNrSuOb5mSSethYTwVZ/4U6x23TyuchoVm22KsPHTLb22LfVGy +E4a9kc1AjcaZ0MK+wkNtv6PlvwIDAQABo1MwUTAdBgNVHQ4EFgQUET0uSUHGinGi +iM1X+s2kMksrcyAwHwYDVR0jBBgwFoAUET0uSUHGinGiiM1X+s2kMksrcyAwDwYD +VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAAI+GTF5myGhO/t1HppYg +JZfIFcSKQiR6LvWMMdE6IV74LPDq4B0nj4cSIsIdVuF3c3Sx6jyDa4tpaBYRVOuL +sLo0zogCqX0g5tnbDT7vGFd7mkYUlzF4yDFKEfsKZIYz4XqXd0lgfJtCyMoohSf2 +YdO0PaAUg4NP2Buy0eE5QDF72ADvjm8HYltlc+9rZCN9lGz5IJnqfDs3mTrZrIRq +E8QELienGUhr5PatMBwkpJ1i1zFdlDRRmphehzHZ6ML3f6C1zfsNtJvtFwcOAJMe +jxozsW8sgBClwFfKfMmVU5RjXbmS0eWt37lKHLLZrwggIu/n5hGutDD83sqle/Am +mFwV3Ltc754FhY3vItVN2XeVTt402BdQL1R3Rl/+nqJ/dkZAifZuzfl9yWjjRYSh +xiAxgl3qqsRpQz5kM/klaFsFaot2ARv8TvB+hv5JWJwEGZuq7ca6nGOX2qVMOoXA +3HOTG0AzNWGYB9GcaGyBqw3iltyZHY5cizXumucELxEb+2mB7NXTBsvWZzzyUvuE +Vd8mkYB5oe6reF1XI31EnaSfnZrqnE4FtQSbZH2nIwSMq+q67p4XhKSprry6sk8P +HgUGgxp1JRYpRMr6aI4Pb1WumjdiXJpgk2F6mo/nPN1QVhkIvlIA2LzC57t7r3mz +EEUWC8tQVPJ1frfcPDKjuwI= +-----END CERTIFICATE-----