initial upload

This commit is contained in:
Ubuntu
2026-01-08 19:41:23 +00:00
commit ae4f1ad1cc
42 changed files with 1371 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
apps:
- vault

1
infrastructure/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
acme/

162
infrastructure/README.md Normal file
View File

@@ -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='<Dein-Root-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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
[local]
localhost ansible_connection=local ansible_python_interpreter=/usr/bin/python3

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
---
collections:
- name: community.docker
version: 3.10.0
- name: community.hashi_vault
version: 6.0.0

View File

@@ -0,0 +1,5 @@
---
- name: Reload Systemd
systemd:
daemon_reload: true

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,2 @@
.env
certs/

View File

@@ -0,0 +1 @@
#Testcomment

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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 <Root-Token>
```
## 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.

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1,7 @@
apps:
- vault
- traefik-sub
- whoami
# Hier einfach weitere Apps aus dem Katalog hinzufügen:
# - nextcloud
# - monitoring

View File

@@ -0,0 +1,2 @@
apps:
- traefik-edge

119
setup_vault_secrets.sh Executable file
View File

@@ -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."

5
terraform/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# Terraform
.terraform/
.terraform.lock.hcl
terraform.tfstate*
terraform.tfvars

16
terraform/data.tf Normal file
View File

@@ -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"
}

25
terraform/locals.tf Normal file
View File

@@ -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" }
}
}

79
terraform/main.tf Normal file
View File

@@ -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
}

20
terraform/providers.tf Normal file
View File

@@ -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
}

67
terraform/variables.tf Normal file
View File

@@ -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
}

26
terraform/versions.tf Normal file
View File

@@ -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"
}
}
}

33
vault-ca.crt Normal file
View File

@@ -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-----