From 3765bc87b6aab79ca8232ad6f926ce39ef113715 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 25 Apr 2026 18:31:06 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=B0=D0=B4=D0=B4=D0=BE=D0=BD=D1=8B=20SMT?= =?UTF-8?q?P=20Relay,=20HashiCorp=20Vault,=20External=20Secrets=20Operator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SMTP Relay (bokysan/mail): Postfix relay через Yandex SMTP, порт 465 с TLS wrappermode, trusted networks only (pod/service CIDR), без аутентификации внутри кластера — поды отправляют на smtp-relay:25 - HashiCorp Vault (hashicorp/vault): standalone и HA (Raft) режимы, auto-unseal: k8s Secret (homelab), AWS KMS, GCP CKMS, Azure Key Vault, Transit; Vault Agent Injector по умолчанию; Job инициализации + Unsealer Deployment для k8s режима; README с полным гайдом по injection в YAML/Helm - External Secrets Operator (ESO): синхронизирует Vault секреты в k8s Secrets, ClusterSecretStore с AppRole auth, README с примерами ExternalSecret в YAML манифестах, Helm чартах и ArgoCD Обновлены: addons.yml (3 новых флага + секции), vault.yml.example (smtp_relay_password, aws_kms_*, eso_approle_secret_id), playbooks/addons.yml, Makefile --- Makefile | 13 + addons/external-secrets/README.md | 288 ++++++++++++++++++ addons/external-secrets/playbook.yml | 7 + .../external-secrets/role/defaults/main.yml | 37 +++ addons/external-secrets/role/tasks/main.yml | 94 ++++++ .../templates/cluster-secret-store.yaml.j2 | 31 ++ addons/smtp-relay/playbook.yml | 7 + addons/smtp-relay/role/defaults/main.yml | 33 ++ addons/smtp-relay/role/tasks/main.yml | 68 +++++ .../role/templates/smtp-relay-values.yaml.j2 | 33 ++ addons/vault/README.md | 284 +++++++++++++++++ addons/vault/playbook.yml | 7 + addons/vault/role/defaults/main.yml | 76 +++++ addons/vault/role/tasks/main.yml | 130 ++++++++ .../role/templates/vault-init-job.yaml.j2 | 143 +++++++++ .../role/templates/vault-unsealer.yaml.j2 | 90 ++++++ .../vault/role/templates/vault-values.yaml.j2 | 181 +++++++++++ group_vars/all/addons.yml | 34 +++ group_vars/all/vault.yml.example | 19 ++ playbooks/addons.yml | 24 ++ 20 files changed, 1599 insertions(+) create mode 100644 addons/external-secrets/README.md create mode 100644 addons/external-secrets/playbook.yml create mode 100644 addons/external-secrets/role/defaults/main.yml create mode 100644 addons/external-secrets/role/tasks/main.yml create mode 100644 addons/external-secrets/role/templates/cluster-secret-store.yaml.j2 create mode 100644 addons/smtp-relay/playbook.yml create mode 100644 addons/smtp-relay/role/defaults/main.yml create mode 100644 addons/smtp-relay/role/tasks/main.yml create mode 100644 addons/smtp-relay/role/templates/smtp-relay-values.yaml.j2 create mode 100644 addons/vault/README.md create mode 100644 addons/vault/playbook.yml create mode 100644 addons/vault/role/defaults/main.yml create mode 100644 addons/vault/role/tasks/main.yml create mode 100644 addons/vault/role/templates/vault-init-job.yaml.j2 create mode 100644 addons/vault/role/templates/vault-unsealer.yaml.j2 create mode 100644 addons/vault/role/templates/vault-values.yaml.j2 diff --git a/Makefile b/Makefile index 1db0f29..539ad8c 100644 --- a/Makefile +++ b/Makefile @@ -57,6 +57,7 @@ DOCKER_RUN := docker run --rm -it \ addon-loki addon-promtail addon-tempo addon-pushgateway \ addon-harbor addon-gitea addon-owncloud addon-nextcloud \ addon-csi-s3 addon-csi-ceph addon-csi-glusterfs addon-vaultwarden \ + addon-smtp-relay addon-vault addon-external-secrets \ add-node remove-node \ add-etcd-node remove-etcd-node \ etcd-backup etcd-restore etcd-list-snapshots \ @@ -386,6 +387,18 @@ addon-vaultwarden: _check_env _check_image ## Установить Vaultwarden @printf "$(CYAN)Устанавливаю Vaultwarden...$(NC)\n" $(DOCKER_RUN) addon vaultwarden $(ARGS) +addon-smtp-relay: _check_env _check_image ## Установить SMTP Relay — Postfix → Yandex (уведомления из подов без внешней авторизации) + @printf "$(CYAN)Устанавливаю SMTP Relay...$(NC)\n" + $(DOCKER_RUN) addon smtp-relay $(ARGS) + +addon-vault: _check_env _check_image ## Установить HashiCorp Vault — менеджер секретов (ARGS="-e vault_mode=ha -e vault_auto_unseal_type=k8s") + @printf "$(CYAN)Устанавливаю HashiCorp Vault...$(NC)\n" + $(DOCKER_RUN) addon vault $(ARGS) + +addon-external-secrets: _check_env _check_image ## Установить External Secrets Operator → Vault/AWS/GCP (ARGS="-e external_secrets_vault_role_id=...") + @printf "$(CYAN)Устанавливаю External Secrets Operator...$(NC)\n" + $(DOCKER_RUN) addon external-secrets $(ARGS) + # Generic цель — любой аддон из addons//playbook.yml addon-%: _check_env _check_image @if [ ! -f "addons/$*/playbook.yml" ]; then \ diff --git a/addons/external-secrets/README.md b/addons/external-secrets/README.md new file mode 100644 index 0000000..5ca9586 --- /dev/null +++ b/addons/external-secrets/README.md @@ -0,0 +1,288 @@ +# External Secrets Operator (ESO) + +Синхронизирует секреты из внешних хранилищ (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager и др.) в нативные Kubernetes Secrets. + +**Когда использовать ESO вместо Vault Agent Injector:** +- Нужны стандартные k8s Secrets (`secretRef`, `envFrom`) — Helm чарты не менять +- Секреты нужны до старта пода (init containers, admission webhooks) +- Нужна синхронизация между namespace +- Нет желания добавлять Vault annotations в каждый Deployment + +## Установка + +```bash +# Базовая установка (настрой ClusterSecretStore вручную после) +make addon-external-secrets + +# Сразу с настроенным ClusterSecretStore → Vault (если AppRole уже создан) +make addon-external-secrets ARGS="-e external_secrets_vault_role_id=" +``` + +## Настройка AppRole в Vault + +ESO использует AppRole для аутентификации в Vault. Выполни один раз: + +```bash +# 1. Port-forward к Vault +kubectl port-forward -n vault svc/vault 8200:8200 & +export VAULT_ADDR=http://localhost:8200 +vault login + +# 2. Включить AppRole auth (если ещё не включён) +vault auth enable approle + +# 3. Создать политику для ESO (read-only на все секреты) +vault policy write eso-policy - <" + secretRef: + name: vault-approle-myapp + key: secretId +``` + +## Мониторинг + +Если `addon_prometheus_stack: true`, метрики ESO доступны в Grafana: +- `external_secrets_sync_calls_total` — количество синхронизаций +- `external_secrets_sync_call_errors_total` — ошибки +- Дашборд: импортируй Grafana Dashboard ID 21045 + +```bash +# Статус всех ExternalSecrets в кластере +kubectl get externalsecrets --all-namespaces + +# Подробности (условие Ready/SecretSynced) +kubectl describe externalsecret myapp-db-secret -n myapp +``` diff --git a/addons/external-secrets/playbook.yml b/addons/external-secrets/playbook.yml new file mode 100644 index 0000000..946ccf6 --- /dev/null +++ b/addons/external-secrets/playbook.yml @@ -0,0 +1,7 @@ +--- +- name: Install External Secrets Operator + hosts: k3s_master[0] + gather_facts: false + become: true + roles: + - role: "{{ playbook_dir }}/../addons/external-secrets/role" diff --git a/addons/external-secrets/role/defaults/main.yml b/addons/external-secrets/role/defaults/main.yml new file mode 100644 index 0000000..9729db0 --- /dev/null +++ b/addons/external-secrets/role/defaults/main.yml @@ -0,0 +1,37 @@ +--- +external_secrets_version: "" # "" = автоматически последняя версия чарта +external_secrets_namespace: "external-secrets" +external_secrets_chart_repo: "https://charts.external-secrets.io" + +# ── Vault backend ───────────────────────────────────────────────────────────── +# Адрес Vault (по умолчанию — внутренний Service addon_vault) +external_secrets_vault_url: "http://vault.vault.svc.cluster.local:8200" + +# Путь к секретам в Vault (KV v2) +external_secrets_vault_kv_path: "secret" +external_secrets_vault_kv_version: "v2" + +# ── Vault AppRole аутентификация для ESO ───────────────────────────────────── +# Role ID задаётся при создании AppRole в Vault (шаг 3 в README.md) +external_secrets_vault_role_id: "" +# Secret ID задаётся в vault.yml: vault_eso_approle_secret_id +external_secrets_vault_secret_id: "{{ vault_eso_approle_secret_id | default('') }}" + +# AppRole path (по умолчанию: approle) +external_secrets_vault_approle_path: "approle" + +# ── ClusterSecretStore ──────────────────────────────────────────────────────── +# Имя глобального ClusterSecretStore для Vault +external_secrets_vault_store_name: "vault-backend" + +# ── Метрики ─────────────────────────────────────────────────────────────────── +external_secrets_metrics_enabled: true + +# ── Ресурсы ─────────────────────────────────────────────────────────────────── +external_secrets_resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi diff --git a/addons/external-secrets/role/tasks/main.yml b/addons/external-secrets/role/tasks/main.yml new file mode 100644 index 0000000..710410b --- /dev/null +++ b/addons/external-secrets/role/tasks/main.yml @@ -0,0 +1,94 @@ +--- +- name: Add External Secrets Helm repo + kubernetes.core.helm_repository: + name: external-secrets + repo_url: "{{ external_secrets_chart_repo }}" + environment: + KUBECONFIG: "{{ k3s_kubeconfig_path }}" + +- name: Get latest External Secrets chart version + ansible.builtin.shell: | + helm search repo external-secrets/external-secrets --output json | \ + python3 -c "import sys,json; print(json.load(sys.stdin)[0]['version'])" + register: _eso_latest_version + changed_when: false + when: external_secrets_version == "" + environment: + KUBECONFIG: "{{ k3s_kubeconfig_path }}" + +- name: Set External Secrets chart version + ansible.builtin.set_fact: + _eso_version: "{{ external_secrets_version if external_secrets_version != '' else _eso_latest_version.stdout | trim }}" + +- name: Install External Secrets Operator via Helm + kubernetes.core.helm: + name: external-secrets + chart_ref: external-secrets/external-secrets + chart_version: "{{ _eso_version }}" + release_namespace: "{{ external_secrets_namespace }}" + create_namespace: true + wait: true + timeout: "5m0s" + values: + resources: + requests: + cpu: "{{ external_secrets_resources.requests.cpu }}" + memory: "{{ external_secrets_resources.requests.memory }}" + limits: + cpu: "{{ external_secrets_resources.limits.cpu }}" + memory: "{{ external_secrets_resources.limits.memory }}" + serviceMonitor: + enabled: "{{ external_secrets_metrics_enabled | bool and addon_prometheus_stack | default(false) | bool }}" + environment: + KUBECONFIG: "{{ k3s_kubeconfig_path }}" + +- name: Wait for External Secrets Operator to be ready + ansible.builtin.command: > + k3s kubectl -n {{ external_secrets_namespace }} + rollout status deployment/external-secrets --timeout=120s + changed_when: false + retries: 3 + delay: 10 + +# ─── ClusterSecretStore → Vault (только если role_id задан) ────────────────── +- name: Template ClusterSecretStore for Vault + ansible.builtin.template: + src: cluster-secret-store.yaml.j2 + dest: /tmp/vault-cluster-secret-store.yaml + mode: '0644' + when: external_secrets_vault_role_id != "" + +- name: Apply ClusterSecretStore and AppRole credentials Secret + ansible.builtin.command: k3s kubectl apply -f /tmp/vault-cluster-secret-store.yaml + changed_when: true + when: external_secrets_vault_role_id != "" + +- name: Show External Secrets Operator access info + ansible.builtin.debug: + msg: + - "External Secrets Operator установлен в namespace: {{ external_secrets_namespace }}" + - "" + - "{% if external_secrets_vault_role_id != '' %}ClusterSecretStore '{{ external_secrets_vault_store_name }}' создан → {{ external_secrets_vault_url }}" + - "{% else %}ClusterSecretStore НЕ создан — задай external_secrets_vault_role_id" + - " Следуй шагам в addons/external-secrets/README.md (раздел 'Настройка AppRole в Vault')" + - "{% endif %}" + - "" + - "Документация: addons/external-secrets/README.md" + - "" + - "Быстрый пример ExternalSecret:" + - " apiVersion: external-secrets.io/v1beta1" + - " kind: ExternalSecret" + - " metadata:" + - " name: my-secret" + - " spec:" + - " refreshInterval: 1h" + - " secretStoreRef:" + - " name: {{ external_secrets_vault_store_name }}" + - " kind: ClusterSecretStore" + - " target:" + - " name: my-k8s-secret" + - " data:" + - " - secretKey: password" + - " remoteRef:" + - " key: myapp/config" + - " property: password" diff --git a/addons/external-secrets/role/templates/cluster-secret-store.yaml.j2 b/addons/external-secrets/role/templates/cluster-secret-store.yaml.j2 new file mode 100644 index 0000000..f9938c9 --- /dev/null +++ b/addons/external-secrets/role/templates/cluster-secret-store.yaml.j2 @@ -0,0 +1,31 @@ +--- +# Kubernetes Secret с AppRole credentials для ESO → Vault +apiVersion: v1 +kind: Secret +metadata: + name: vault-approle-credentials + namespace: {{ external_secrets_namespace }} +type: Opaque +stringData: + roleId: "{{ external_secrets_vault_role_id }}" + secretId: "{{ external_secrets_vault_secret_id }}" +--- +# ClusterSecretStore: глобальная точка доступа к Vault для всех namespace +apiVersion: external-secrets.io/v1beta1 +kind: ClusterSecretStore +metadata: + name: {{ external_secrets_vault_store_name }} +spec: + provider: + vault: + server: "{{ external_secrets_vault_url }}" + path: "{{ external_secrets_vault_kv_path }}" + version: "{{ external_secrets_vault_kv_version }}" + auth: + appRole: + path: "{{ external_secrets_vault_approle_path }}" + roleId: "{{ external_secrets_vault_role_id }}" + secretRef: + name: vault-approle-credentials + namespace: {{ external_secrets_namespace }} + key: secretId diff --git a/addons/smtp-relay/playbook.yml b/addons/smtp-relay/playbook.yml new file mode 100644 index 0000000..b79bb63 --- /dev/null +++ b/addons/smtp-relay/playbook.yml @@ -0,0 +1,7 @@ +--- +- name: Install SMTP Relay (Postfix → Yandex) + hosts: k3s_master[0] + gather_facts: false + become: true + roles: + - role: "{{ playbook_dir }}/../addons/smtp-relay/role" diff --git a/addons/smtp-relay/role/defaults/main.yml b/addons/smtp-relay/role/defaults/main.yml new file mode 100644 index 0000000..b403757 --- /dev/null +++ b/addons/smtp-relay/role/defaults/main.yml @@ -0,0 +1,33 @@ +--- +smtp_relay_namespace: "smtp-relay" +smtp_relay_chart_repo: "https://bokysan.github.io/docker-postfix/" +smtp_relay_version: "" # "" = автоматически последняя версия чарта + +# ── Upstream SMTP сервер ────────────────────────────────────────────────────── +smtp_relay_host: "smtp.yandex.ru" +smtp_relay_port: 465 # 465 = SMTPS (TLS wrapper), 587 = STARTTLS +smtp_relay_username: "sergey@antropoff.ru" +# Пароль задаётся в vault.yml: vault_smtp_relay_password +smtp_relay_password: "{{ vault_smtp_relay_password | default('') }}" + +# Имя отправителя по умолчанию (for envelope-from rewriting) +smtp_relay_from: "vault@antropoff.ru" + +# Разрешённые домены отправителей (через пробел) +smtp_relay_allowed_sender_domains: "antropoff.ru" + +# ── Доступ к relay ──────────────────────────────────────────────────────────── +# Только доверенные сети (pod CIDR + service CIDR + localhost) +# Задай значения соответствующие твоему кластеру +smtp_relay_mynetworks: "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8" + +# ── Развёртывание ───────────────────────────────────────────────────────────── +smtp_relay_replicas: 1 + +smtp_relay_resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi diff --git a/addons/smtp-relay/role/tasks/main.yml b/addons/smtp-relay/role/tasks/main.yml new file mode 100644 index 0000000..d6b10e2 --- /dev/null +++ b/addons/smtp-relay/role/tasks/main.yml @@ -0,0 +1,68 @@ +--- +- name: Add bokysan Helm repo (docker-postfix SMTP relay) + kubernetes.core.helm_repository: + name: bokysan + repo_url: "{{ smtp_relay_chart_repo }}" + environment: + KUBECONFIG: "{{ k3s_kubeconfig_path }}" + +- name: Get latest smtp-relay chart version + ansible.builtin.shell: | + helm search repo bokysan/mail --output json | \ + python3 -c "import sys,json; print(json.load(sys.stdin)[0]['version'])" + register: _smtp_relay_latest_version + changed_when: false + when: smtp_relay_version == "" + environment: + KUBECONFIG: "{{ k3s_kubeconfig_path }}" + +- name: Set smtp-relay chart version + ansible.builtin.set_fact: + _smtp_relay_version: "{{ smtp_relay_version if smtp_relay_version != '' else _smtp_relay_latest_version.stdout | trim }}" + +- name: Template SMTP Relay values + ansible.builtin.template: + src: smtp-relay-values.yaml.j2 + dest: /tmp/smtp-relay-values.yaml + mode: '0600' + +- name: Install SMTP Relay via Helm + kubernetes.core.helm: + name: smtp-relay + chart_ref: bokysan/mail + chart_version: "{{ _smtp_relay_version }}" + release_namespace: "{{ smtp_relay_namespace }}" + create_namespace: true + wait: true + timeout: "3m0s" + values_files: + - /tmp/smtp-relay-values.yaml + environment: + KUBECONFIG: "{{ k3s_kubeconfig_path }}" + +- name: Wait for SMTP Relay to be ready + ansible.builtin.command: > + k3s kubectl -n {{ smtp_relay_namespace }} + rollout status deployment/smtp-relay --timeout=120s + changed_when: false + retries: 3 + delay: 10 + +- name: Show SMTP Relay access info + ansible.builtin.debug: + msg: + - "SMTP Relay установлен в namespace: {{ smtp_relay_namespace }}" + - "Service: smtp-relay.{{ smtp_relay_namespace }}.svc.cluster.local:25" + - "Upstream: {{ smtp_relay_host }}:{{ smtp_relay_port }} (TLS wrappermode)" + - "Username: {{ smtp_relay_username }}" + - "Trusted networks: {{ smtp_relay_mynetworks }}" + - "Allowed sender domains: {{ smtp_relay_allowed_sender_domains }}" + - "" + - "Использование в подах (пример):" + - " SMTP_HOST=smtp-relay.{{ smtp_relay_namespace }}.svc.cluster.local" + - " SMTP_PORT=25" + - " SMTP_FROM={{ smtp_relay_from }}" + - " (без аутентификации — relay принимает из trusted networks)" + - "" + - "Тест: kubectl run -it --rm smtp-test --image=alpine --restart=Never -- sh" + - " apk add mailx && echo 'test' | mail -s 'test' -r {{ smtp_relay_from }} you@example.com -S smtp=smtp://smtp-relay.{{ smtp_relay_namespace }}.svc.cluster.local" diff --git a/addons/smtp-relay/role/templates/smtp-relay-values.yaml.j2 b/addons/smtp-relay/role/templates/smtp-relay-values.yaml.j2 new file mode 100644 index 0000000..e0008f8 --- /dev/null +++ b/addons/smtp-relay/role/templates/smtp-relay-values.yaml.j2 @@ -0,0 +1,33 @@ +replicaCount: {{ smtp_relay_replicas }} + +config: + general: + # Upstream relay — скобки отключают MX-lookup, порт явный + RELAYHOST: "[{{ smtp_relay_host }}]:{{ smtp_relay_port }}" + RELAYHOST_USERNAME: "{{ smtp_relay_username }}" + # Только эти сети могут использовать relay + MYNETWORKS: "{{ smtp_relay_mynetworks }}" + # Разрешённые домены отправителей + ALLOWED_SENDER_DOMAINS: "{{ smtp_relay_allowed_sender_domains }}" + # TLS: encrypt = обязательный TLS + RELAYHOST_TLS_LEVEL: "encrypt" + # Port 465 = SMTPS (TLS wrapper mode), требует явного флага + POSTFIX_smtp_tls_wrappermode: "yes" + # Запрет открытого релея — принимаем только от MYNETWORKS + POSTFIX_smtpd_relay_restrictions: "permit_mynetworks reject" + POSTFIX_smtpd_recipient_restrictions: "permit_mynetworks reject_unauth_destination" + + secret: + RELAYHOST_PASSWORD: "{{ smtp_relay_password }}" + +resources: + requests: + cpu: "{{ smtp_relay_resources.requests.cpu }}" + memory: "{{ smtp_relay_resources.requests.memory }}" + limits: + cpu: "{{ smtp_relay_resources.limits.cpu }}" + memory: "{{ smtp_relay_resources.limits.memory }}" + +service: + type: ClusterIP + port: 25 diff --git a/addons/vault/README.md b/addons/vault/README.md new file mode 100644 index 0000000..eae59f2 --- /dev/null +++ b/addons/vault/README.md @@ -0,0 +1,284 @@ +# HashiCorp Vault + +Self-hosted менеджер секретов с шифрованием, аудитом и fine-grained политиками доступа. + +## Установка + +```bash +# Standalone (по умолчанию, 1 Pod) +make addon-vault + +# HA (3 Pods с Raft, нужны минимум 3 ноды) +make addon-vault ARGS="-e vault_mode=ha" + +# С авто-unseal через k8s Secret (homelab/dev) +make addon-vault ARGS="-e vault_auto_unseal_type=k8s" + +# С AWS KMS авто-unseal (production) +make addon-vault ARGS="-e vault_auto_unseal_type=aws -e vault_aws_kms_region=us-east-1 -e vault_aws_kms_key_id=..." + +# С Ingress +make addon-vault ARGS="-e vault_ingress_enabled=true -e vault_ingress_host=vault.example.com" +``` + +## Первичная инициализация (ручной режим) + +После установки Vault нужно инициализировать (только один раз): + +```bash +# Инициализация (генерирует unseal keys + root token) +kubectl exec -n vault vault-0 -- vault operator init \ + -key-shares=5 \ + -key-threshold=3 + +# ВАЖНО: сохрани 5 unseal keys и root token в надёжное место! +# Пример вывода: +# Unseal Key 1: abc... +# Unseal Key 2: def... +# ... +# Initial Root Token: hvs.xxxxx + +# Unseal (нужно подать 3 из 5 ключей) +kubectl exec -n vault vault-0 -- vault operator unseal +kubectl exec -n vault vault-0 -- vault operator unseal +kubectl exec -n vault vault-0 -- vault operator unseal + +# Проверка +kubectl exec -n vault vault-0 -- vault status +``` + +## Авто-unseal режимы + +### k8s Secret (homelab, не для production) +```bash +make addon-vault ARGS="-e vault_auto_unseal_type=k8s" +``` +Vault автоматически инициализируется и unseal keys сохраняются в k8s Secret `vault-unseal-keys`. +Unsealer Deployment следит за состоянием и unseals при перезапуске. + +**Получить root token:** +```bash +kubectl get secret vault-unseal-keys -n vault \ + -o jsonpath='{.data.root_token}' | base64 -d +``` + +### AWS KMS (production) +```yaml +# group_vars/all/addons.yml +vault_auto_unseal_type: "aws" +vault_aws_kms_region: "us-east-1" +vault_aws_kms_key_id: "arn:aws:kms:us-east-1:..." + +# group_vars/all/vault.yml +vault_aws_kms_access_key: "AKIAIOSFODNN7EXAMPLE" +vault_aws_kms_secret_key: "wJalrXUtnFEMI/K7MDENG/..." +``` + +### Transit Seal (через другой Vault) +```yaml +vault_auto_unseal_type: "transit" +vault_transit_address: "https://vault-primary.example.com" +vault_transit_key_name: "autounseal" +# vault.yml: +vault_transit_seal_token: "hvs.CAESIxxxxxx" +``` + +## Работа с секретами + +### CLI (локально через port-forward) +```bash +# Port-forward +kubectl port-forward -n vault svc/vault 8200:8200 & + +# Авторизация +export VAULT_ADDR=http://localhost:8200 +vault login + +# Включить KV v2 engine +vault secrets enable -path=secret kv-v2 + +# Записать секрет +vault kv put secret/myapp/config \ + db_password="supersecret" \ + api_key="abc123" + +# Прочитать секрет +vault kv get secret/myapp/config +vault kv get -field=db_password secret/myapp/config +``` + +### CLI (из пода в кластере) +```bash +kubectl exec -n vault vault-0 -- env VAULT_ADDR=http://localhost:8200 \ + vault kv put secret/myapp/config db_password="supersecret" +``` + +## Kubernetes Auth Method + +Позволяет подам авторизоваться через их ServiceAccount токен: + +```bash +# Включить kubernetes auth +vault auth enable kubernetes + +# Настроить +vault write auth/kubernetes/config \ + kubernetes_host="https://kubernetes.default.svc.cluster.local:443" + +# Создать политику доступа +vault policy write myapp-policy - < + k3s kubectl -n {{ vault_namespace }} + wait pod -l app.kubernetes.io/name=vault + --for=condition=Ready --timeout=120s + changed_when: false + retries: 5 + delay: 15 + failed_when: false + +# ─── K8S AUTO-UNSEAL (только при vault_auto_unseal_type: k8s) ──────────────── +- name: Template Vault init Job (k8s auto-unseal) + ansible.builtin.template: + src: vault-init-job.yaml.j2 + dest: /tmp/vault-init-job.yaml + mode: '0644' + when: vault_auto_unseal_type == "k8s" + +- name: Delete previous vault-init Job if exists + ansible.builtin.command: > + k3s kubectl delete job vault-init -n {{ vault_namespace }} --ignore-not-found + changed_when: false + when: vault_auto_unseal_type == "k8s" + +- name: Apply Vault init Job + ansible.builtin.command: k3s kubectl apply -f /tmp/vault-init-job.yaml + changed_when: true + when: vault_auto_unseal_type == "k8s" + +- name: Wait for Vault init Job to complete + ansible.builtin.command: > + k3s kubectl -n {{ vault_namespace }} + wait job/vault-init --for=condition=complete --timeout=300s + changed_when: false + retries: 3 + delay: 10 + when: vault_auto_unseal_type == "k8s" + +- name: Template Vault unsealer Deployment + ansible.builtin.template: + src: vault-unsealer.yaml.j2 + dest: /tmp/vault-unsealer.yaml + mode: '0644' + when: vault_auto_unseal_type == "k8s" + +- name: Apply Vault unsealer Deployment + ansible.builtin.command: k3s kubectl apply -f /tmp/vault-unsealer.yaml + changed_when: true + when: vault_auto_unseal_type == "k8s" + +# ─── Получаем статус ───────────────────────────────────────────────────────── +- name: Check Vault init status + ansible.builtin.command: > + k3s kubectl -n {{ vault_namespace }} + exec vault-0 -- vault status -format=json + register: _vault_status + changed_when: false + failed_when: false + +- name: Parse Vault status + ansible.builtin.set_fact: + _vault_initialized: "{{ (_vault_status.stdout | from_json).initialized | default(false) }}" + _vault_sealed: "{{ (_vault_status.stdout | from_json).sealed | default(true) }}" + when: _vault_status.rc == 0 + failed_when: false + +- name: Show HashiCorp Vault access info + ansible.builtin.debug: + msg: + - "HashiCorp Vault установлен в namespace: {{ vault_namespace }}" + - "Режим: {{ vault_mode }}{% if vault_mode == 'ha' %} ({{ vault_ha_replicas }} реплики, Raft){% endif %}" + - "Auto-unseal: {{ vault_auto_unseal_type }}" + - "Инициализирован: {{ _vault_initialized | default('неизвестно') }}" + - "Sealed: {{ _vault_sealed | default('неизвестно') }}" + - "" + - "{% if vault_ingress_enabled %}UI: https://{{ vault_ingress_host }}{% else %}UI (port-forward): kubectl port-forward -n {{ vault_namespace }} svc/vault 8200:8200{% endif %}" + - "Internal API: http://vault.{{ vault_namespace }}.svc.cluster.local:8200" + - "" + - "{% if vault_auto_unseal_type == 'none' %}=== РУЧНАЯ ИНИЦИАЛИЗАЦИЯ (если ещё не выполнена) ===" + - " kubectl exec -n {{ vault_namespace }} vault-0 -- vault operator init" + - " # Сохрани unseal keys и root token!" + - " kubectl exec -n {{ vault_namespace }} vault-0 -- vault operator unseal " + - " kubectl exec -n {{ vault_namespace }} vault-0 -- vault operator unseal " + - " kubectl exec -n {{ vault_namespace }} vault-0 -- vault operator unseal " + - "{% elif vault_auto_unseal_type == 'k8s' %}Ключи сохранены в Secret: {{ vault_auto_unseal_k8s_secret }} (namespace: {{ vault_namespace }})" + - " kubectl get secret {{ vault_auto_unseal_k8s_secret }} -n {{ vault_namespace }} -o jsonpath='{.data.root_token}' | base64 -d" + - "{% endif %}" + - "Документация: addons/vault/README.md" diff --git a/addons/vault/role/templates/vault-init-job.yaml.j2 b/addons/vault/role/templates/vault-init-job.yaml.j2 new file mode 100644 index 0000000..89b1ea3 --- /dev/null +++ b/addons/vault/role/templates/vault-init-job.yaml.j2 @@ -0,0 +1,143 @@ +--- +# RBAC для Job инициализации и авто-unseal +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vault-init + namespace: {{ vault_namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: vault-init + namespace: {{ vault_namespace }} +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: vault-init + namespace: {{ vault_namespace }} +subjects: + - kind: ServiceAccount + name: vault-init + namespace: {{ vault_namespace }} +roleRef: + kind: Role + name: vault-init + apiGroup: rbac.authorization.k8s.io +--- +# Job: инициализирует Vault и сохраняет ключи в k8s Secret +apiVersion: batch/v1 +kind: Job +metadata: + name: vault-init + namespace: {{ vault_namespace }} + labels: + app: vault-init +spec: + backoffLimit: 5 + ttlSecondsAfterFinished: 300 + template: + metadata: + labels: + app: vault-init + spec: + serviceAccountName: vault-init + restartPolicy: OnFailure + containers: + - name: vault-init + image: hashicorp/vault:latest + env: + - name: VAULT_ADDR + value: "http://vault.{{ vault_namespace }}.svc.cluster.local:8200" + - name: VAULT_NAMESPACE + value: "{{ vault_namespace }}" + - name: UNSEAL_SECRET_NAME + value: "{{ vault_auto_unseal_k8s_secret }}" + - name: KEY_SHARES + value: "{{ vault_auto_unseal_k8s_shares }}" + - name: KEY_THRESHOLD + value: "{{ vault_auto_unseal_k8s_threshold }}" + command: + - /bin/sh + - -c + - | + set -e + + echo "Waiting for Vault to start..." + until vault status 2>&1 | grep -q "Initialized"; do + echo " Vault not ready yet, waiting..." + sleep 5 + done + + # Проверяем: уже инициализирован? + INIT_STATUS=$(vault status -format=json 2>/dev/null | grep -o '"initialized":[^,}]*' | grep -o '[^:]*$' | tr -d ' ') + if [ "$INIT_STATUS" = "true" ]; then + echo "Vault is already initialized." + # Проверяем: уже unlocked (секрет существует)? + if kubectl get secret "$UNSEAL_SECRET_NAME" -n "$VAULT_NAMESPACE" >/dev/null 2>&1; then + echo "Unseal secret already exists. Nothing to do." + exit 0 + fi + echo "WARNING: Vault initialized but unseal keys not found in cluster. Manual unseal required." + exit 0 + fi + + echo "Initializing Vault with $KEY_SHARES shares, threshold $KEY_THRESHOLD..." + INIT_OUTPUT=$(vault operator init \ + -key-shares="$KEY_SHARES" \ + -key-threshold="$KEY_THRESHOLD" \ + -format=json) + + echo "Saving unseal keys and root token to Secret '$UNSEAL_SECRET_NAME'..." + # Формируем Secret с ключами + ROOT_TOKEN=$(echo "$INIT_OUTPUT" | grep -o '"root_token":"[^"]*"' | cut -d'"' -f4) + + # Создаём kubectl secret + SECRET_ARGS="--from-literal=root_token=$ROOT_TOKEN" + i=1 + echo "$INIT_OUTPUT" | grep -o '"unseal_keys_b64":\[[^]]*\]' | grep -o '"[A-Za-z0-9+/=]*"' | while read KEY; do + KEY_VAL=$(echo "$KEY" | tr -d '"') + SECRET_ARGS="$SECRET_ARGS --from-literal=unseal_key_$i=$KEY_VAL" + i=$((i + 1)) + done + + # Используем python для надёжного парсинга JSON + python3 - <" diff --git a/addons/vault/role/templates/vault-unsealer.yaml.j2 b/addons/vault/role/templates/vault-unsealer.yaml.j2 new file mode 100644 index 0000000..4cf3842 --- /dev/null +++ b/addons/vault/role/templates/vault-unsealer.yaml.j2 @@ -0,0 +1,90 @@ +--- +# RBAC для авто-unsealer Deployment +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vault-unsealer + namespace: {{ vault_namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: vault-unsealer + namespace: {{ vault_namespace }} +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: vault-unsealer + namespace: {{ vault_namespace }} +subjects: + - kind: ServiceAccount + name: vault-unsealer + namespace: {{ vault_namespace }} +roleRef: + kind: Role + name: vault-unsealer + apiGroup: rbac.authorization.k8s.io +--- +# Deployment: следит за Vault и unseals при перезапуске +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vault-unsealer + namespace: {{ vault_namespace }} + labels: + app: vault-unsealer +spec: + replicas: 1 + selector: + matchLabels: + app: vault-unsealer + template: + metadata: + labels: + app: vault-unsealer + spec: + serviceAccountName: vault-unsealer + containers: + - name: unsealer + image: hashicorp/vault:latest + env: + - name: VAULT_ADDR + value: "http://vault.{{ vault_namespace }}.svc.cluster.local:8200" + - name: VAULT_NAMESPACE_K8S + value: "{{ vault_namespace }}" + - name: UNSEAL_SECRET_NAME + value: "{{ vault_auto_unseal_k8s_secret }}" + - name: KEY_THRESHOLD + value: "{{ vault_auto_unseal_k8s_threshold }}" + command: + - /bin/sh + - -c + - | + while true; do + STATUS=$(vault status -format=json 2>/dev/null) + if echo "$STATUS" | grep -q '"sealed":true'; then + echo "$(date): Vault is sealed — unsealing..." + for i in $(seq 1 $KEY_THRESHOLD); do + KEY=$(kubectl get secret "$UNSEAL_SECRET_NAME" \ + -n "$VAULT_NAMESPACE_K8S" \ + -o "jsonpath={.data.unseal_key_$i}" 2>/dev/null | base64 -d) + if [ -n "$KEY" ]; then + vault operator unseal "$KEY" >/dev/null + fi + done + echo "$(date): Unseal applied ($KEY_THRESHOLD keys)." + fi + sleep 30 + done + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 64Mi diff --git a/addons/vault/role/templates/vault-values.yaml.j2 b/addons/vault/role/templates/vault-values.yaml.j2 new file mode 100644 index 0000000..7f00a80 --- /dev/null +++ b/addons/vault/role/templates/vault-values.yaml.j2 @@ -0,0 +1,181 @@ +global: + enabled: true + +injector: + enabled: {{ vault_injector_enabled | lower }} + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 128Mi + +server: + resources: + requests: + cpu: "{{ vault_resources.requests.cpu }}" + memory: "{{ vault_resources.requests.memory }}" + limits: + cpu: "{{ vault_resources.limits.cpu }}" + memory: "{{ vault_resources.limits.memory }}" + + dataStorage: + enabled: true + size: "{{ vault_storage_size }}" +{% if vault_storage_class %} + storageClass: "{{ vault_storage_class }}" +{% endif %} + + serviceMonitor: + enabled: {{ (vault_metrics_enabled | bool and addon_prometheus_stack | default(false) | bool) | lower }} + +{% if vault_ingress_enabled | bool %} + ingress: + enabled: true + ingressClassName: "{{ vault_ingress_class }}" + hosts: + - host: "{{ vault_ingress_host }}" + paths: + - / +{% if vault_ingress_tls | bool %} + tls: + - secretName: vault-tls + hosts: + - "{{ vault_ingress_host }}" + annotations: + cert-manager.io/cluster-issuer: "{{ vault_ingress_cert_issuer }}" +{% endif %} +{% endif %} + +{% if vault_auto_unseal_type == "aws" %} + extraEnvironmentVars: + AWS_ACCESS_KEY_ID: "{{ vault_aws_access_key }}" + AWS_SECRET_ACCESS_KEY: "{{ vault_aws_secret_key }}" +{% elif vault_auto_unseal_type == "gcp" %} + extraVolumes: + - type: secret + name: vault-gcp-credentials + extraEnvironmentVars: + GOOGLE_APPLICATION_CREDENTIALS: "/vault/userconfig/vault-gcp-credentials/credentials.json" +{% elif vault_auto_unseal_type == "azure" %} + extraEnvironmentVars: + AZURE_TENANT_ID: "{{ vault_azure_tenant_id }}" + AZURE_CLIENT_ID: "{{ vault_azure_client_id }}" + AZURE_CLIENT_SECRET: "{{ vault_azure_client_secret }}" +{% endif %} + +{% if vault_mode == "standalone" %} + standalone: + enabled: true + config: | + ui = true + + listener "tcp" { + tls_disable = 1 + address = "[::]:8200" + cluster_address = "[::]:8201" + } + + storage "file" { + path = "/vault/data" + } +{% if vault_auto_unseal_type == "aws" %} + + seal "awskms" { + region = "{{ vault_aws_kms_region }}" + kms_key_id = "{{ vault_aws_kms_key_id }}" + } +{% elif vault_auto_unseal_type == "gcp" %} + + seal "gcpckms" { + project = "{{ vault_gcp_project }}" + region = "{{ vault_gcp_region }}" + key_ring = "{{ vault_gcp_key_ring }}" + crypto_key = "{{ vault_gcp_crypto_key }}" + } +{% elif vault_auto_unseal_type == "azure" %} + + seal "azurekeyvault" { + vault_name = "{{ vault_azure_keyvault_name }}" + key_name = "{{ vault_azure_key_name }}" + } +{% elif vault_auto_unseal_type == "transit" %} + + seal "transit" { + address = "{{ vault_transit_address }}" + token = "{{ vault_transit_token }}" + key_name = "{{ vault_transit_key_name }}" + mount_path = "transit/" + disable_renewal = "false" + } +{% endif %} + + ha: + enabled: false +{% endif %} + +{% if vault_mode == "ha" %} + standalone: + enabled: false + + ha: + enabled: true + replicas: {{ vault_ha_replicas }} + raft: + enabled: true + setNodeId: true + config: | + ui = true + + listener "tcp" { + tls_disable = 1 + address = "[::]:8200" + cluster_address = "[::]:8201" + } + + storage "raft" { + path = "/vault/data" +{% for i in range(vault_ha_replicas) %} + retry_join { + leader_api_addr = "http://vault-{{ i }}.vault-internal:8200" + } +{% endfor %} + } + + service_registration "kubernetes" {} +{% if vault_auto_unseal_type == "aws" %} + + seal "awskms" { + region = "{{ vault_aws_kms_region }}" + kms_key_id = "{{ vault_aws_kms_key_id }}" + } +{% elif vault_auto_unseal_type == "gcp" %} + + seal "gcpckms" { + project = "{{ vault_gcp_project }}" + region = "{{ vault_gcp_region }}" + key_ring = "{{ vault_gcp_key_ring }}" + crypto_key = "{{ vault_gcp_crypto_key }}" + } +{% elif vault_auto_unseal_type == "azure" %} + + seal "azurekeyvault" { + vault_name = "{{ vault_azure_keyvault_name }}" + key_name = "{{ vault_azure_key_name }}" + } +{% elif vault_auto_unseal_type == "transit" %} + + seal "transit" { + address = "{{ vault_transit_address }}" + token = "{{ vault_transit_token }}" + key_name = "{{ vault_transit_key_name }}" + mount_path = "transit/" + disable_renewal = "false" + } +{% endif %} +{% endif %} + +ui: + enabled: true + serviceType: ClusterIP diff --git a/group_vars/all/addons.yml b/group_vars/all/addons.yml index 89f4a60..9185e43 100644 --- a/group_vars/all/addons.yml +++ b/group_vars/all/addons.yml @@ -33,6 +33,9 @@ addon_csi_s3: false # CSI S3 Driver (объектное хран addon_csi_ceph: false # CSI Ceph / Rook-Ceph (distributed block + filesystem storage) addon_csi_glusterfs: false # CSI GlusterFS Driver (требует внешний GlusterFS + Heketi) addon_vaultwarden: false # Vaultwarden (self-hosted Bitwarden-совместимый менеджер паролей) +addon_smtp_relay: false # SMTP Relay (Postfix → Yandex/другой SMTP) — для уведомлений из подов +addon_vault: false # HashiCorp Vault (секреты, PKI, динамические credentials) +addon_external_secrets: false # External Secrets Operator → Vault/AWS/GCP (k8s Secret sync) # ─── NFS Server ─────────────────────────────────────────────────────────────── nfs_exports: @@ -287,6 +290,37 @@ minio_api_ingress_host: "s3.example.com" # vaultwarden_smtp_security: "force_tls" # force_tls | starttls | off # vaultwarden_smtp_username: "user@example.com" +# ─── SMTP Relay ─────────────────────────────────────────────────────────────── +# Postfix relay для отправки почты из подов через внешний SMTP (Yandex/Gmail/etc). +# Пароль задаётся в vault.yml: vault_smtp_relay_password +# Использование: SMTP_HOST=smtp-relay.smtp-relay.svc.cluster.local, SMTP_PORT=25 +# smtp_relay_host: "smtp.yandex.ru" +# smtp_relay_port: 465 +# smtp_relay_username: "sergey@antropoff.ru" +# smtp_relay_from: "vault@antropoff.ru" +# smtp_relay_allowed_sender_domains: "antropoff.ru" +# smtp_relay_mynetworks: "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8" + +# ─── HashiCorp Vault ────────────────────────────────────────────────────────── +# Менеджер секретов. README: addons/vault/README.md +# vault_mode: "standalone" # standalone (1 pod) | ha (3 pods Raft) +# vault_auto_unseal_type: "none" # none | k8s | aws | gcp | azure | transit +# k8s: ключи в Secret (homelab), aws/gcp/azure/transit (production) +# vault_injector_enabled: true # Vault Agent Injector (авто-инжекция в поды) +# vault_ingress_enabled: false +# vault_ingress_host: "vault-hc.example.com" +# vault_storage_size: "10Gi" +# Для AWS KMS unseal (vault.yml): vault_aws_kms_access_key, vault_aws_kms_secret_key + +# ─── External Secrets Operator ──────────────────────────────────────────────── +# Синхронизирует секреты из Vault/AWS/GCP в k8s Secrets. README: addons/external-secrets/README.md +# Требует предварительно созданного AppRole в Vault (шаги в README). +# external_secrets_vault_url: "http://vault.vault.svc.cluster.local:8200" +# external_secrets_vault_kv_path: "secret" +# external_secrets_vault_role_id: "" # после создания AppRole в Vault +# external_secrets_vault_store_name: "vault-backend" +# Пароль задаётся в vault.yml: vault_eso_approle_secret_id + # ─── etcd backup ────────────────────────────────────────────────────────────── etcd_backup_dir: "{{ k3s_data_dir }}/server/db/snapshots" etcd_backup_retention: 5 # сколько снимков хранить diff --git a/group_vars/all/vault.yml.example b/group_vars/all/vault.yml.example index a260d90..5ea00a0 100644 --- a/group_vars/all/vault.yml.example +++ b/group_vars/all/vault.yml.example @@ -69,3 +69,22 @@ vault_vaultwarden_smtp_password: "fntwztnkacanpbwa" # пароль SMTP (Y # ─── CSI GlusterFS / Heketi ──────────────────────────────────────────────────── vault_glusterfs_heketi_secret: "changeme-heketi" # пароль Heketi admin + +# ─── SMTP Relay ──────────────────────────────────────────────────────────────── +vault_smtp_relay_password: "fntwztnkacanpbwa" # Yandex App Password для sergey@antropoff.ru + +# ─── HashiCorp Vault (auto-unseal: aws) ──────────────────────────────────────── +# Используется только при vault_auto_unseal_type: aws +vault_aws_kms_access_key: "" # AWS IAM Access Key ID +vault_aws_kms_secret_key: "" # AWS IAM Secret Access Key + +# Vault Transit Seal (vault_auto_unseal_type: transit) +vault_transit_seal_token: "" # token для доступа к transit engine другого Vault + +# Azure Key Vault unseal (vault_auto_unseal_type: azure) +vault_azure_kv_client_secret: "" + +# ─── External Secrets Operator → HashiCorp Vault ─────────────────────────────── +# Получить после шага 6 в addons/external-secrets/README.md: +# vault write -f -field=secret_id auth/approle/role/eso-role/secret-id +vault_eso_approle_secret_id: "" # AppRole Secret ID для ESO → Vault diff --git a/playbooks/addons.yml b/playbooks/addons.yml index 04e47bb..7ad1eee 100644 --- a/playbooks/addons.yml +++ b/playbooks/addons.yml @@ -231,3 +231,27 @@ when: addon_vaultwarden | default(false) | bool roles: - role: "{{ playbook_dir }}/../addons/vaultwarden/role" + +- name: Install SMTP Relay + hosts: k3s_master[0] + gather_facts: false + become: true + when: addon_smtp_relay | default(false) | bool + roles: + - role: "{{ playbook_dir }}/../addons/smtp-relay/role" + +- name: Install HashiCorp Vault + hosts: k3s_master[0] + gather_facts: false + become: true + when: addon_vault | default(false) | bool + roles: + - role: "{{ playbook_dir }}/../addons/vault/role" + +- name: Install External Secrets Operator + hosts: k3s_master[0] + gather_facts: false + become: true + when: addon_external_secrets | default(false) | bool + roles: + - role: "{{ playbook_dir }}/../addons/external-secrets/role"