feat: 纳管共享 etcd 与 k3s 外 PostgreSQL 高可用及备份
yaml / yaml (pull_request) Successful in 41s
ansible / collection-test (pull_request) Successful in 2m41s
terraform / validate (pull_request) Successful in 2m41s
ansible / lint (pull_request) Successful in 4m36s

This commit is contained in:
2026-09-25 19:34:48 +00:00
parent acd4b55722
commit 3a2fe5fa0c
92 changed files with 4211 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.terraform/
*.tfstate*
*.tfplan
*.tfvars
!*.tfvars.example
__pycache__/
+6
View File
@@ -0,0 +1,6 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
retry_files_enabled = False
host_key_checking = True
interpreter_python = auto_silent
@@ -0,0 +1,60 @@
---
# 与常规 site 分离,防止重建/恢复时默默创建新的认证域。
- name: 初始化共享 etcd 认证
hosts: etcd[0]
become: true
gather_facts: false
pre_tasks:
- name: 加载共享默认参数
ansible.builtin.import_role:
name: shared_etcd
tasks_from: context
environment:
ETCDCTL_ENDPOINTS: "https://{{ etcd_address }}:{{ etcd_client_port }}"
ETCDCTL_CACERT: "{{ etcd_config_dir }}/ca.crt"
ETCDCTL_CERT: "{{ etcd_config_dir }}/admin.crt"
ETCDCTL_KEY: "{{ etcd_config_dir }}/admin.key"
tasks:
- name: 读取认证状态
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, auth, status]
changed_when: false
register: etcd_auth_status
check_mode: false
- name: 初始化管理员与认证
when: not ((etcd_auth_status.stdout | from_json).enabled | default(false))
block:
- name: 要求显式初始化参数
ansible.builtin.assert:
that: etcd_bootstrap_auth | default(false) | bool
fail_msg: 首次初始化需要 -e etcd_bootstrap_auth=true;常规运行不能重建认证。
- name: 读取现有用户
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, user, list]
changed_when: false
register: etcd_users
- name: 创建仅证书认证的 root 用户
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", user, add, root, --no-password]
when: "'root' not in ((etcd_users.stdout | from_json).users | default([], true))"
changed_when: true
- name: 读取管理员角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, user, get, root]
changed_when: false
register: etcd_root_roles
- name: 授予 root 管理角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", user, grant-role, root, root]
when: "'root' not in ((etcd_root_roles.stdout | from_json).roles | default([], true))"
changed_when: true
- name: 开启认证
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", auth, enable]
changed_when: true
+37
View File
@@ -0,0 +1,37 @@
---
- name: 收敛消费者账号及独立 prefix
hosts: etcd[0]
become: true
gather_facts: false
pre_tasks:
- name: 加载共享默认参数
ansible.builtin.import_role:
name: shared_etcd
tasks_from: context
environment:
ETCDCTL_ENDPOINTS: "https://{{ etcd_address }}:{{ etcd_client_port }}"
ETCDCTL_CACERT: "{{ etcd_config_dir }}/ca.crt"
ETCDCTL_CERT: "{{ etcd_config_dir }}/admin.crt"
ETCDCTL_KEY: "{{ etcd_config_dir }}/admin.key"
tasks:
- name: 检查认证状态
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, auth, status]
register: etcd_auth_status
changed_when: false
check_mode: false
- name: 要求认证已启用
ansible.builtin.assert:
that:
- ((etcd_auth_status.stdout | from_json).enabled | default(false))
- etcd_consumers | map(attribute='name') | unique | length == etcd_consumers | length
- etcd_bao_token | length > 0
no_log: true
- name: 逐个收敛消费者
ansible.builtin.include_tasks: tasks/consumer.yml
loop: "{{ etcd_consumers }}"
loop_control:
loop_var: etcd_consumer
label: "{{ etcd_consumer.name }}"
@@ -0,0 +1,74 @@
---
# 仅在 Terraform 身份配置完成后部署;预检失败不启用定时器。
- name: 安装独立于人工会话的续签调度
hosts: etcd-laptop
become: true
gather_facts: false
tasks:
- name: 创建 root 管理的程序目录
ansible.builtin.file:
path: /opt/homelab-etcd-controller
state: directory
mode: '0755'
- name: 安装证书登录与调度程序
ansible.builtin.copy:
src: "../controller/{{ item }}"
dest: "/opt/homelab-etcd-controller/{{ item }}"
owner: root
group: root
mode: '0755'
loop: [login.py, renew.py]
- name: 以机器身份预检(不输出 token)
ansible.builtin.command:
argv: [/usr/bin/python3, /opt/homelab-etcd-controller/login.py]
changed_when: false
no_log: true
- name: 安装固定的 Ansible 配置副本
ansible.builtin.copy:
src: "{{ playbook_dir }}/"
dest: /opt/homelab-etcd-controller/ansible/
owner: root
group: root
mode: preserve
- name: 创建调度状态目录
ansible.builtin.file:
path: /var/lib/homelab-etcd-controller
state: directory
owner: panxiao81
group: panxiao81
mode: '0700'
- name: 安装续签 oneshot unit
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-etcd-renew.service
mode: '0644'
content: |
[Unit]
Description=Renew shared etcd certificates through Bao machine identity
After=network-online.target
[Service]
Type=oneshot
User=panxiao81
Environment=HOME=/home/panxiao81
Environment=PATH=/home/panxiao81/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/python3 /opt/homelab-etcd-controller/renew.py
TimeoutStartSec=30min
UMask=0077
- name: 安装每日续签 timer
ansible.builtin.copy:
dest: /etc/systemd/system/homelab-etcd-renew.timer
mode: '0644'
content: |
[Unit]
Description=Daily shared etcd certificate renewal check
[Timer]
OnCalendar=*-*-* 04:10:00 UTC
RandomizedDelaySec=15min
Persistent=true
[Install]
WantedBy=timers.target
- name: 启用续签调度
ansible.builtin.systemd_service:
name: homelab-etcd-renew.timer
daemon_reload: true
enabled: true
state: started
@@ -0,0 +1,20 @@
---
etcd_bao_url: https://bao.ad.ddupan.top:8200
etcd_bao_pki_mount: pki
etcd_bao_kv_mount: kv
etcd_bao_secret_base: infra/etcd/consumers
# 只在控制端使用,不下发 Bao token。也不读取或复制历史 .vault_pass。
etcd_bao_token: "{{ lookup('env', 'BAO_TOKEN') }}"
etcd_consumers:
- name: patroni-pg-prod
prefix: /homelab/patroni/pg-prod/
etcd_lxc_template: laptop:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst
etcd_lxc_storage: pve-rg
etcd_lxc_bridge: labnet
etcd_lxc_gateway: 10.60.0.1
etcd_lxc_memory: 512
etcd_lxc_disk_gb: 8
etcd_lxc_pubkey: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
# 与现有 LAN exporter 相同的内网采集边界;禁止映射到公网。
etcd_metrics_urls: "http://127.0.0.1:2381,http://{{ etcd_address }}:2381"
@@ -0,0 +1,33 @@
---
# 已部署成员;共置 PG standby/备份的承载预算,数据库数据用独立 HDD mp0。
all:
vars:
ansible_user: root
children:
etcd:
hosts:
etcd-laptop:
ansible_connection: local
ansible_host: 192.168.10.127
ansible_user: panxiao81
etcd_address: 192.168.10.127
etcd-pve1:
ansible_host: 10.60.0.20
etcd_address: 10.60.0.20
etcd-pve2:
ansible_host: 10.60.0.21
etcd_address: 10.60.0.21
etcd_pve:
hosts:
pve1:
ansible_host: 192.168.10.4
etcd_lxc_vmid: 150
etcd_lxc_hostname: etcd-pve1
etcd_lxc_address: 10.60.0.20/24
etcd_lxc_memory: 1536
pve2:
ansible_host: 192.168.10.7
etcd_lxc_vmid: 151
etcd_lxc_hostname: etcd-pve2
etcd_lxc_address: 10.60.0.21/24
etcd_lxc_memory: 768
+126
View File
@@ -0,0 +1,126 @@
---
# 只创建已声明且尚不存在的 LXC;不接管未知 VMID,不重启现有容器。
- name: 创建独立 etcd LXC
hosts: etcd_pve
become: true
gather_facts: false
tasks:
- name: 读取全局资源,避免 VMID 在其他节点已占用
ansible.builtin.command:
argv: [pvesh, get, /cluster/resources, --type, vm, --output-format, json]
register: etcd_pve_resources
changed_when: false
check_mode: false
- name: 保存同号资源
ansible.builtin.set_fact:
etcd_lxc_existing: >-
{{ etcd_pve_resources.stdout
| from_json
| selectattr('vmid', 'equalto', etcd_lxc_vmid)
| list }}
- name: 拒绝接管未知资源
ansible.builtin.assert:
that:
- >-
etcd_lxc_existing | length == 0 or
(etcd_lxc_existing[0].type == 'lxc' and etcd_lxc_existing[0].node == inventory_hostname
and etcd_lxc_existing[0].name == etcd_lxc_hostname
and 'shared-etcd' in (etcd_lxc_existing[0].tags | default('')))
- name: 新建无特权 LXC
when: etcd_lxc_existing | length == 0 and not ansible_check_mode
block:
- name: 暂存 SSH 公钥
ansible.builtin.copy:
content: "{{ etcd_lxc_pubkey }}\n"
dest: /run/shared-etcd-bootstrap.pub
mode: '0600'
- name: 创建声明的容器
ansible.builtin.command:
argv:
- pct
- create
- "{{ etcd_lxc_vmid }}"
- "{{ etcd_lxc_template }}"
- --hostname
- "{{ etcd_lxc_hostname }}"
- --unprivileged
- '1'
- --cores
- '1'
- --memory
- "{{ etcd_lxc_memory }}"
- --swap
- '0'
- --rootfs
- "{{ etcd_lxc_storage }}:{{ etcd_lxc_disk_gb }}"
- --net0
- "name=eth0,bridge={{ etcd_lxc_bridge }},ip={{ etcd_lxc_address }},gw={{ etcd_lxc_gateway }},type=veth"
- --nameserver
- 192.168.10.5
- --searchdomain
- ad.ddupan.top
- --ssh-public-keys
- /run/shared-etcd-bootstrap.pub
- --onboot
- '1'
- --tags
- ansible;shared-etcd
changed_when: true
always:
- name: 删除暂存公钥
ansible.builtin.file:
path: /run/shared-etcd-bootstrap.pub
state: absent
- name: 读取容器配置
ansible.builtin.command:
argv: [pct, config, "{{ etcd_lxc_vmid }}"]
changed_when: false
register: etcd_lxc_config
when: not ansible_check_mode or etcd_lxc_existing | length > 0
- name: 配置漂移先报错,不直接改运行中的网络/资源
ansible.builtin.assert:
that:
- >-
('ip=' ~ etcd_lxc_address ~ ',') in etcd_lxc_config.stdout or
('ip=' ~ etcd_lxc_address ~ '\n') in etcd_lxc_config.stdout
- "('bridge=' ~ etcd_lxc_bridge ~ ',') in etcd_lxc_config.stdout"
- "'unprivileged: 1' in etcd_lxc_config.stdout"
- "('memory: ' ~ etcd_lxc_memory) in etcd_lxc_config.stdout"
- "('rootfs: ' ~ etcd_lxc_storage ~ ':') in etcd_lxc_config.stdout"
when: etcd_lxc_config is not skipped
- name: 读取容器运行状态
ansible.builtin.command:
argv: [pct, status, "{{ etcd_lxc_vmid }}"]
changed_when: false
register: etcd_lxc_status
when: not ansible_check_mode
- name: 启动容器
ansible.builtin.command:
argv: [pct, start, "{{ etcd_lxc_vmid }}"]
changed_when: true
when: not ansible_check_mode and 'running' not in etcd_lxc_status.stdout
- name: 通过可信宿主机取得容器 SSH 公钥
ansible.builtin.command:
argv: [pct, exec, "{{ etcd_lxc_vmid }}", --, cat, /etc/ssh/ssh_host_ed25519_key.pub]
register: etcd_lxc_hostkey
changed_when: false
retries: 12
delay: 5
until: etcd_lxc_hostkey.rc == 0
when: not ansible_check_mode
- name: 保存经宿主机验证的 SSH host key
ansible.builtin.known_hosts:
name: "{{ etcd_lxc_address.split('/')[0] }}"
key: "{{ etcd_lxc_address.split('/')[0] }} {{ etcd_lxc_hostkey.stdout }}"
delegate_to: localhost
become: false
when: not ansible_check_mode
@@ -0,0 +1,90 @@
---
# 首次部署承载调整,逐节点迁移;普通 lxc.yml 不自动移动磁盘。
- name: 逐个将新 etcd 容器迁入声明的 SSD 池
hosts: etcd_pve
become: true
gather_facts: false
serial: 1
any_errors_fatal: true
vars:
etcd_move_health_command:
- /opt/homelab-etcd/etcdctl
- --endpoints=https://192.168.10.127:2379,https://10.60.0.20:2379,https://10.60.0.21:2379
- --cacert=/etc/homelab-etcd/ca.crt
- --cert=/etc/homelab-etcd/admin.crt
- --key=/etc/homelab-etcd/admin.key
- endpoint
- health
tasks:
- name: 核对容器配置
ansible.builtin.command:
argv: [pct, config, "{{ etcd_lxc_vmid }}"]
register: etcd_move_config
changed_when: false
check_mode: false
- name: 限定本项目新建容器与允许的源池
ansible.builtin.assert:
that:
- etcd_lxc_vmid in [150, 151]
- "('hostname: ' ~ etcd_lxc_hostname) in etcd_move_config.stdout"
- "'shared-etcd' in etcd_move_config.stdout"
- "'rootfs: local-lvm:' in etcd_move_config.stdout or 'rootfs: pve-rg:' in etcd_move_config.stdout"
- etcd_lxc_storage == 'pve-rg'
- name: 验证迁移前全部端点健康
ansible.builtin.command:
argv: "{{ etcd_move_health_command }}"
delegate_to: localhost
changed_when: false
check_mode: false
- name: 迁移当前尚在本地池的根卷
when: "'rootfs: local-lvm:' in etcd_move_config.stdout and not ansible_check_mode"
block:
- name: 创建已验证的集群快照
ansible.builtin.command:
argv: [systemctl, start, homelab-etcd-snapshot.service]
delegate_to: localhost
changed_when: true
- name: 正常关闭一个容器
ansible.builtin.command:
argv: [pct, shutdown, "{{ etcd_lxc_vmid }}", --timeout, '60']
changed_when: true
- name: 复制成功后移除该新建容器的原卷
ansible.builtin.command:
argv:
- pct
- move-volume
- "{{ etcd_lxc_vmid }}"
- rootfs
- "{{ etcd_lxc_storage }}"
- --delete
- '1'
- --bwlimit
- '32768'
changed_when: true
always:
- name: 核对容器运行状态
ansible.builtin.command:
argv: [pct, status, "{{ etcd_lxc_vmid }}"]
register: etcd_move_status
changed_when: false
- name: 重新启动容器
ansible.builtin.command:
argv: [pct, start, "{{ etcd_lxc_vmid }}"]
when: "'running' not in etcd_move_status.stdout"
changed_when: true
- name: 等待当前成员回归
ansible.builtin.command:
argv: "{{ etcd_move_health_command }}"
delegate_to: localhost
changed_when: false
register: etcd_move_health
retries: 18
delay: 5
until: etcd_move_health.rc == 0
when: not ansible_check_mode
@@ -0,0 +1,4 @@
---
collections:
- name: community.crypto
version: 3.2.1
@@ -0,0 +1,17 @@
---
etcd_version: 3.7.2
etcd_archive_checksum: sha256:3a3679bc51a4ee9d30bccea1da7cd4fe62c6fc1d2ca1255068d2c53bf3026135
etcd_install_dir: /opt/homelab-etcd
etcd_config_dir: /etc/homelab-etcd
etcd_data_dir: /var/lib/homelab-etcd
etcd_cluster_token: homelab-shared-etcd-v1
etcd_client_port: 2379
etcd_peer_port: 2380
etcd_metrics_port: 2381
etcd_quota_bytes: 268435456
etcd_memory_high: 256M
etcd_memory_max: 384M
etcd_certificate_ttl: 1440h
etcd_renew_before: +14d
etcd_snapshot_dir: /var/backups/homelab-etcd
etcd_snapshot_keep: 3
@@ -0,0 +1,93 @@
---
- name: 在成员本地生成私钥
community.crypto.openssl_privatekey:
path: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.key"
type: ECC
curve: secp256r1
owner: root
group: "{{ etcd_cert.group }}"
mode: '0640'
- name: 本地生成 CSR
community.crypto.openssl_csr:
path: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.csr"
privatekey_path: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.key"
common_name: "{{ etcd_cert.cn | default(omit, true) }}"
subject_alt_name: >-
{{ ['IP:' ~ etcd_address, 'DNS:' ~ inventory_hostname] if etcd_cert.name in ['server', 'peer']
else (['DNS:' ~ inventory_hostname] if etcd_cert.name == 'gateway' else []) }}
use_common_name_for_san: false
extended_key_usage: "{{ etcd_cert.eku }}"
key_usage: [digitalSignature]
mode: '0644'
register: etcd_csr_state
- name: 检查已有证书
ansible.builtin.stat:
path: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.crt"
register: etcd_cert_file
- name: 检查续签窗口
community.crypto.x509_certificate_info:
path: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.crt"
valid_at:
renewal: "{{ etcd_renew_before }}"
register: etcd_cert_info
when: etcd_cert_file.stat.exists
- name: 通过中央 CA 签发需更新的证书
when: >-
not etcd_cert_file.stat.exists or etcd_csr_state is changed or
not (etcd_cert_info.valid_at.renewal | default(false))
block:
- name: 仅在确需签发时要求 Bao 凭据
ansible.builtin.assert:
that: etcd_bao_token | length > 0
no_log: true
- name: 读取 CSR 公共内容
ansible.builtin.slurp:
src: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.csr"
register: etcd_csr
- name: 控制端提交 Bao 签名请求
ansible.builtin.uri:
url: "{{ etcd_bao_url }}/v1/{{ etcd_bao_pki_mount }}/sign/{{ etcd_cert.role }}"
method: POST
headers:
X-Vault-Token: "{{ etcd_bao_token }}"
body_format: json
body:
csr: "{{ etcd_csr.content | b64decode }}"
ttl: "{{ etcd_certificate_ttl }}"
status_code: 200
delegate_to: localhost
become: false
register: etcd_signed
no_log: true
when: not ansible_check_mode
- name: 保存签发的证书链
ansible.builtin.copy:
content: |
{{ etcd_signed.json.data.certificate }}
{{ etcd_signed.json.data.ca_chain | join('\n') }}
dest: "{{ etcd_config_dir }}/{{ etcd_cert.name }}.crt"
owner: root
group: "{{ etcd_cert.group }}"
mode: '0644'
when: not ansible_check_mode
- name: 保存中央 CA 信任链
ansible.builtin.copy:
content: |
{{ etcd_signed.json.data.ca_chain | join('\n') }}
dest: "{{ etcd_config_dir }}/ca.crt"
owner: root
group: homelab-etcd
mode: '0644'
when: not ansible_check_mode
- name: 标记证书已更新
ansible.builtin.set_fact:
etcd_certificates_changed: true
@@ -0,0 +1,3 @@
---
# 只加载 role defaults,供运维入口复用。
[]
@@ -0,0 +1,134 @@
---
- name: 验证拓扑和签发配置
ansible.builtin.assert:
that:
- groups['etcd'] | length == 3
- groups['etcd'] | map('extract', hostvars, 'etcd_address') | unique | length == 3
- ansible_facts['architecture'] == 'x86_64'
- etcd_bao_url is match('^https://')
- etcd_archive_checksum is match('^sha256:[a-f0-9]{64}$')
no_log: true
- name: 安装证书处理依赖
ansible.builtin.package:
name: [python3-cryptography, openssl]
state: present
- name: 创建独立 etcd 组
ansible.builtin.group:
name: homelab-etcd
system: true
- name: 创建独立 etcd 用户
ansible.builtin.user:
name: homelab-etcd
group: homelab-etcd
system: true
shell: /usr/sbin/nologin
create_home: false
- name: 创建受管目录
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: homelab-etcd
mode: "{{ item.mode }}"
loop:
- {path: "{{ etcd_config_dir }}", owner: root, mode: '0750'}
- {path: "{{ etcd_data_dir }}", owner: homelab-etcd, mode: '0700'}
- {path: "{{ etcd_install_dir }}", owner: root, mode: '0755'}
- name: 下载固定版本及校验归档
ansible.builtin.get_url:
url: >-
https://github.com/etcd-io/etcd/releases/download/v{{ etcd_version }}/etcd-v{{ etcd_version }}-linux-amd64.tar.gz
dest: "{{ etcd_install_dir }}/etcd-v{{ etcd_version }}.tar.gz"
checksum: "{{ etcd_archive_checksum }}"
mode: '0644'
register: etcd_download
retries: 3
delay: 5
until: etcd_download is succeeded
- name: 展开固定版本
ansible.builtin.unarchive:
src: "{{ etcd_install_dir }}/etcd-v{{ etcd_version }}.tar.gz"
dest: "{{ etcd_install_dir }}"
remote_src: true
creates: "{{ etcd_install_dir }}/etcd-v{{ etcd_version }}-linux-amd64/etcd"
- name: 安装版本链接
ansible.builtin.file:
src: "{{ etcd_install_dir }}/etcd-v{{ etcd_version }}-linux-amd64/{{ item }}"
dest: "{{ etcd_install_dir }}/{{ item }}"
state: link
loop: [etcd, etcdctl, etcdutl]
register: etcd_binary_links
- name: 签发成员与管理员证书
ansible.builtin.include_tasks: certificate.yml
loop:
- {name: server, role: homelab-etcd-server, cn: "{{ inventory_hostname }}", eku: [serverAuth], group: homelab-etcd}
- {name: peer, role: homelab-etcd-peer, cn: homelab-etcd-peer, eku: [serverAuth, clientAuth], group: homelab-etcd}
- {name: gateway, role: homelab-etcd-gateway, cn: "", eku: [clientAuth], group: homelab-etcd}
- {name: admin, role: homelab-etcd-admin, cn: root, eku: [clientAuth], group: root}
loop_control:
loop_var: etcd_cert
- name: 写入独立 etcd 配置
ansible.builtin.template:
src: etcd.yml.j2
dest: "{{ etcd_config_dir }}/etcd.yml"
owner: root
group: homelab-etcd
mode: '0640'
register: etcd_config_file
- name: 写入独立 systemd unit
ansible.builtin.template:
src: homelab-etcd.service.j2
dest: /etc/systemd/system/homelab-etcd.service
mode: '0644'
register: etcd_unit
# 跨失败重跑记录激活状态,防止上一轮写文件后中断导致漏掉必要重启。
- name: 计算受管文件校验和
ansible.builtin.stat:
path: "{{ item }}"
checksum_algorithm: sha256
loop:
- "{{ etcd_config_dir }}/etcd.yml"
- "{{ etcd_config_dir }}/server.crt"
- "{{ etcd_config_dir }}/peer.crt"
- "{{ etcd_config_dir }}/gateway.crt"
- "{{ etcd_config_dir }}/ca.crt"
- /etc/systemd/system/homelab-etcd.service
register: etcd_managed_files
- name: 计算期望激活指纹
ansible.builtin.set_fact:
etcd_config_fingerprint: >-
{{ ((etcd_managed_files.results | map(attribute='stat.checksum') | list | join(':'))
~ ':' ~ etcd_version) | hash('sha256') }}
when: not ansible_check_mode
- name: 检查已激活指纹
ansible.builtin.stat:
path: "{{ etcd_config_dir }}/activated.sha256"
register: etcd_activated_file
- name: 读取已激活指纹
ansible.builtin.slurp:
src: "{{ etcd_config_dir }}/activated.sha256"
register: etcd_activated
when: etcd_activated_file.stat.exists
- name: 判断是否需要滚动激活
ansible.builtin.set_fact:
etcd_config_changed: >-
{{ not etcd_activated_file.stat.exists or
(etcd_activated.content | default('') | b64decode | trim) != etcd_config_fingerprint | default('check-mode') }}
- name: 管理本地快照任务
ansible.builtin.import_tasks: snapshot.yml
@@ -0,0 +1,26 @@
---
- name: 创建仅 root 可访问的快照目录
ansible.builtin.file:
path: "{{ etcd_snapshot_dir }}"
state: directory
owner: root
group: root
mode: '0700'
- name: 写入快照脚本和 systemd 任务
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- {src: snapshot.sh.j2, dest: "{{ etcd_install_dir }}/snapshot", mode: '0700'}
- {src: snapshot.service.j2, dest: /etc/systemd/system/homelab-etcd-snapshot.service, mode: '0644'}
- {src: snapshot.timer.j2, dest: /etc/systemd/system/homelab-etcd-snapshot.timer, mode: '0644'}
register: etcd_snapshot_units
- name: 启用本地快照计划
ansible.builtin.systemd_service:
name: homelab-etcd-snapshot.timer
daemon_reload: "{{ etcd_snapshot_units is changed }}"
enabled: true
state: started
@@ -0,0 +1,33 @@
# Ansible 管理;与 k3s、数据库生命周期独立。
name: {{ inventory_hostname | to_json }}
data-dir: {{ etcd_data_dir | to_json }}
listen-client-urls: https://{{ etcd_address }}:{{ etcd_client_port }}
advertise-client-urls: https://{{ etcd_address }}:{{ etcd_client_port }}
listen-peer-urls: https://{{ etcd_address }}:{{ etcd_peer_port }}
initial-advertise-peer-urls: https://{{ etcd_address }}:{{ etcd_peer_port }}
initial-cluster: "{% for member in groups['etcd'] %}{{ member }}=https://{{ hostvars[member].etcd_address }}:{{ etcd_peer_port }}{{ ',' if not loop.last else '' }}{% endfor %}"
initial-cluster-token: {{ etcd_cluster_token | to_json }}
initial-cluster-state: new
# 已存在的数据目录优先;成员替换必须走单独 runbook,不删除数据重建。
client-transport-security:
cert-file: {{ etcd_config_dir }}/server.crt
key-file: {{ etcd_config_dir }}/server.key
client-cert-file: {{ etcd_config_dir }}/gateway.crt
client-key-file: {{ etcd_config_dir }}/gateway.key
trusted-ca-file: {{ etcd_config_dir }}/ca.crt
client-cert-auth: true
peer-transport-security:
cert-file: {{ etcd_config_dir }}/peer.crt
key-file: {{ etcd_config_dir }}/peer.key
trusted-ca-file: {{ etcd_config_dir }}/ca.crt
client-cert-auth: true
allowed-cn: [homelab-etcd-peer]
# 独立 metrics listener 仅提供指标/健康,不开放 KV API;只绑定受管内网地址。
listen-metrics-urls: {{ etcd_metrics_urls | default("http://127.0.0.1:" ~ etcd_metrics_port) | to_json }}
quota-backend-bytes: {{ etcd_quota_bytes }}
auto-compaction-mode: periodic
auto-compaction-retention: '1h'
heartbeat-interval: 100
election-timeout: 1000
logger: zap
log-level: info
@@ -0,0 +1,23 @@
[Unit]
Description=Homelab shared etcd
Wants=network-online.target
After=network-online.target
[Service]
User=homelab-etcd
Group=homelab-etcd
ExecStart={{ etcd_install_dir }}/etcd --config-file={{ etcd_config_dir }}/etcd.yml
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
MemoryHigh={{ etcd_memory_high }}
MemoryMax={{ etcd_memory_max }}
UMask=0077
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths={{ etcd_data_dir }}
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,11 @@
[Unit]
Description=Snapshot homelab shared etcd
After=homelab-etcd.service
[Service]
Type=oneshot
ExecStart={{ etcd_install_dir }}/snapshot
User=root
UMask=0077
TimeoutStartSec=300
Nice=10
@@ -0,0 +1,20 @@
#!/bin/bash
set -euo pipefail
umask 077
export ETCDCTL_ENDPOINTS="https://{{ etcd_address }}:{{ etcd_client_port }}"
export ETCDCTL_CACERT="{{ etcd_config_dir }}/ca.crt"
export ETCDCTL_CERT="{{ etcd_config_dir }}/admin.crt"
export ETCDCTL_KEY="{{ etcd_config_dir }}/admin.key"
repo="{{ etcd_snapshot_dir }}"
exec 9>"$repo/.lock"
flock -n 9 || exit 0
output="$repo/$(date -u +%Y%m%dT%H%M%SZ).db"
trap 'rm -f "$output.partial" "$output.partial.part"' EXIT
{{ etcd_install_dir }}/etcdctl snapshot save "$output.partial"
{{ etcd_install_dir }}/etcdutl snapshot status "$output.partial" >/dev/null
mv "$output.partial" "$output"
# 只有新快照成功且验证可读才清理旧备份。目录仅由此任务管理。
mapfile -t snapshots < <(find "$repo" -maxdepth 1 -type f -name '????????T??????Z.db' -printf '%f\n' | sort -r)
for old in "${snapshots[@]:{{ etcd_snapshot_keep }}}"; do
rm -- "$repo/$old"
done
@@ -0,0 +1,10 @@
[Unit]
Description=Daily homelab etcd snapshot
[Timer]
OnCalendar=*-*-* 03:20:00 UTC
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target
+66
View File
@@ -0,0 +1,66 @@
---
# 先配置全体,再滚动启动:首次集群形成不能在第一个节点等待 quorum。
- name: 配置 shared etcd 成员
hosts: etcd
become: true
roles:
- shared_etcd
- name: 滚动启动并验证 shared etcd
hosts: etcd
become: true
serial: 1
pre_tasks:
- name: 加载共享默认参数
ansible.builtin.import_role:
name: shared_etcd
tasks_from: context
tasks:
- name: 启动已配置的成员
ansible.builtin.systemd_service:
name: homelab-etcd
enabled: true
daemon_reload: true
state: "{{ 'restarted' if etcd_config_changed | bool else 'started' }}"
- name: 等待本机客户端端口
ansible.builtin.wait_for:
host: "{{ etcd_address }}"
port: "{{ etcd_client_port }}"
timeout: 60
when: not ansible_check_mode
- name: 已有集群每次激活后等待本成员恢复 quorum 通信
ansible.builtin.command:
argv:
- "{{ etcd_install_dir }}/etcdctl"
- --endpoints=https://{{ etcd_address }}:{{ etcd_client_port }}
- --cacert={{ etcd_config_dir }}/ca.crt
- --cert={{ etcd_config_dir }}/admin.crt
- --key={{ etcd_config_dir }}/admin.key
- endpoint
- health
changed_when: false
register: etcd_member_health
retries: 12
delay: 5
until: etcd_member_health.rc == 0
when: etcd_activated_file.stat.exists and not ansible_check_mode
- name: 核对全部成员
ansible.builtin.import_playbook: verify.yml
- name: 记录已成功激活的配置
hosts: etcd
become: true
pre_tasks:
- name: 加载共享默认参数
ansible.builtin.import_role:
name: shared_etcd
tasks_from: context
tasks:
- name: 写入激活指纹(全体健康检查通过后)
ansible.builtin.copy:
content: "{{ etcd_config_fingerprint }}\n"
dest: "{{ etcd_config_dir }}/activated.sha256"
mode: '0644'
when: not ansible_check_mode
@@ -0,0 +1,170 @@
---
- name: 验证消费者范围
ansible.builtin.assert:
that:
- etcd_consumer.name is match('^[a-z][a-z0-9-]+$')
- etcd_consumer.name != 'root'
- etcd_consumer.prefix is match('^/homelab/[a-zA-Z0-9/_-]+/$')
- etcd_consumer.prefix | length > 10
- name: 读取已有用户和角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, "{{ item }}", list]
loop: [user, role]
register: etcd_identities
changed_when: false
check_mode: false
# 404 data 也可能代表被删除/销毁的旧秘密;只有 metadata 不存在才允许生成。
- name: 控制端读取 Bao 秘密元数据
ansible.builtin.uri:
url: "{{ etcd_bao_url }}/v1/{{ etcd_bao_kv_mount }}/metadata/{{ etcd_bao_secret_base }}/{{ etcd_consumer.name }}"
headers:
X-Vault-Token: "{{ etcd_bao_token }}"
status_code: [200, 404]
delegate_to: localhost
become: false
no_log: true
register: etcd_secret_metadata
check_mode: false
- name: 禁止已有用户丢失秘密后自动换密码
ansible.builtin.assert:
that: >-
etcd_secret_metadata.status == 200 or
etcd_consumer.name not in ((etcd_identities.results[0].stdout | from_json).users | default([], true))
fail_msg: etcd 用户已存在但 Bao 秘密缺失;需恢复原秘密或执行显式轮换。
- name: 首次创建随机秘密且禁止覆盖已有版本
ansible.builtin.uri:
url: "{{ etcd_bao_url }}/v1/{{ etcd_bao_kv_mount }}/data/{{ etcd_bao_secret_base }}/{{ etcd_consumer.name }}"
method: POST
headers:
X-Vault-Token: "{{ etcd_bao_token }}"
body_format: json
body:
options: {cas: 0}
data:
username: "{{ etcd_consumer.name }}"
password: "{{ lookup('ansible.builtin.password', '/dev/null', length=48, chars=['ascii_letters', 'digits']) }}"
prefix: "{{ etcd_consumer.prefix }}"
status_code: 200
delegate_to: localhost
become: false
no_log: true
changed_when: true
when:
- etcd_secret_metadata.status == 404
- not ansible_check_mode
- name: 控制端读取既有秘密
ansible.builtin.uri:
url: "{{ etcd_bao_url }}/v1/{{ etcd_bao_kv_mount }}/data/{{ etcd_bao_secret_base }}/{{ etcd_consumer.name }}"
headers:
X-Vault-Token: "{{ etcd_bao_token }}"
status_code: 200
delegate_to: localhost
become: false
register: etcd_consumer_secret
no_log: true
when: etcd_secret_metadata.status == 200 or not ansible_check_mode
check_mode: false
- name: 核对已保存秘密归属
ansible.builtin.assert:
that:
- etcd_consumer_secret.json.data.data.username == etcd_consumer.name
- etcd_consumer_secret.json.data.data.prefix == etcd_consumer.prefix
- etcd_consumer_secret.json.data.data.password | length >= 32
no_log: true
when: etcd_secret_metadata.status == 200 or not ansible_check_mode
- name: 新建消费者用户(密码只通过 stdin 传递)
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", user, add, "{{ etcd_consumer.name }}", --interactive=false]
stdin: "{{ etcd_consumer_secret.json.data.data.password }}"
no_log: true
changed_when: true
when:
- etcd_consumer.name not in ((etcd_identities.results[0].stdout | from_json).users | default([], true))
- not ansible_check_mode
- name: 新建消费者角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", role, add, "{{ etcd_consumer.name }}"]
changed_when: true
when: etcd_consumer.name not in ((etcd_identities.results[1].stdout | from_json).roles | default([], true))
- name: 读取角色权限
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, role, get, "{{ etcd_consumer.name }}"]
register: etcd_role_state
changed_when: false
when: >-
not ansible_check_mode or
etcd_consumer.name in ((etcd_identities.results[1].stdout | from_json).roles | default([], true))
- name: 拒绝不符合声明的既有权限(不自动扩大或删除)
ansible.builtin.assert:
that:
- >-
((etcd_role_state.stdout | from_json).perm | default([], true)) in
[[], [{'permType': 2, 'key': etcd_consumer.prefix | b64encode,
'range_end': (etcd_consumer.prefix[:-1] ~ '0') | b64encode}]]
fail_msg: 既有角色权限与声明不同,请显式审查权限迁移。
when: etcd_role_state is not skipped
- name: 授予唯一 prefix 读写权限
ansible.builtin.command:
argv:
- "{{ etcd_install_dir }}/etcdctl"
- role
- grant-permission
- "{{ etcd_consumer.name }}"
- readwrite
- "{{ etcd_consumer.prefix }}"
- --prefix=true
changed_when: true
when:
- etcd_role_state is not skipped
- ((etcd_role_state.stdout | from_json).perm | default([], true)) | length == 0
- name: 读取用户角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", --write-out=json, user, get, "{{ etcd_consumer.name }}"]
register: etcd_user_state
changed_when: false
when: >-
not ansible_check_mode or
etcd_consumer.name in ((etcd_identities.results[0].stdout | from_json).users | default([], true))
- name: 拒绝消费者已有额外角色
ansible.builtin.assert:
that: >-
((etcd_user_state.stdout | from_json).roles | default([], true))
| difference([etcd_consumer.name]) | length == 0
when: etcd_user_state is not skipped
- name: 绑定消费者角色
ansible.builtin.command:
argv: ["{{ etcd_install_dir }}/etcdctl", user, grant-role, "{{ etcd_consumer.name }}", "{{ etcd_consumer.name }}"]
changed_when: true
when:
- etcd_user_state is not skipped
- etcd_consumer.name not in ((etcd_user_state.stdout | from_json).roles | default([], true))
# 使用 gateway 的真实密码登录来核对 Bao 与 etcd 一致性,不能只看用户已存在。
- name: 验证消费者密码可经 gateway 登录
ansible.builtin.uri:
url: "https://{{ etcd_address }}:{{ etcd_client_port }}/v3/auth/authenticate"
method: POST
client_cert: "{{ etcd_config_dir }}/gateway.crt"
client_key: "{{ etcd_config_dir }}/gateway.key"
ca_path: "{{ etcd_config_dir }}/ca.crt"
body_format: json
body:
name: "{{ etcd_consumer.name }}"
password: "{{ etcd_consumer_secret.json.data.data.password }}"
status_code: 200
no_log: true
when: not ansible_check_mode
+29
View File
@@ -0,0 +1,29 @@
---
- name: 验证 shared etcd 全部端点
hosts: etcd[0]
become: true
gather_facts: false
pre_tasks:
- name: 加载共享默认参数
ansible.builtin.import_role:
name: shared_etcd
tasks_from: context
tasks:
- name: 通过管理员 mTLS 检查所有端点健康
ansible.builtin.command:
argv:
- "{{ etcd_install_dir }}/etcdctl"
- >-
--endpoints={{ groups['etcd'] | map('extract', hostvars, 'etcd_address')
| map('regex_replace', '^(.*)$', 'https://\1:' ~ etcd_client_port) | join(',') }}
- --cacert={{ etcd_config_dir }}/ca.crt
- --cert={{ etcd_config_dir }}/admin.crt
- --key={{ etcd_config_dir }}/admin.key
- endpoint
- health
changed_when: false
register: etcd_health
retries: 12
delay: 5
until: etcd_health.rc == 0
when: not ansible_check_mode
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/python3
"""仅由受控调用者捕获 stdout;不得手动运行以免在终端输出短期 token。"""
import json
import ssl
import sys
import urllib.request
def main():
context = ssl.create_default_context()
context.load_cert_chain('/etc/homelab-etcd/peer.crt', '/etc/homelab-etcd/peer.key')
request = urllib.request.Request(
'https://bao.ad.ddupan.top:8200/v1/auth/homelab-etcd-renewal/login',
data=json.dumps({'name': 'etcd-laptop'}).encode(),
headers={'Content-Type': 'application/json'}, method='POST',
)
with urllib.request.urlopen(request, context=context, timeout=30) as response:
token = json.load(response)['auth']['client_token']
if not isinstance(token, str) or not token:
raise ValueError('empty token')
sys.stdout.write(token)
if __name__ == '__main__':
try:
main()
except Exception:
# Bao 响应和异常对象可能带敏感内容,不写入 journal。
sys.stderr.write('Bao certificate login failed\n')
sys.exit(1)
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/python3
"""每日一次,机器证书换短期 token;使用现有串行 Ansible 收敛,不常驻 agent。"""
import fcntl
import os
from pathlib import Path
import subprocess
import sys
import urllib.request
ROOT = Path('/opt/homelab-etcd-controller')
def main():
with open('/var/lib/homelab-etcd-controller/renew.lock', 'a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return 0
login = subprocess.run(
['sudo', '-n', '/usr/bin/python3', str(ROOT / 'login.py')],
capture_output=True, text=True, timeout=40,
)
if login.returncode or not login.stdout.strip():
print('Bao 机器证书登录失败;保留现有证书与运行中的 etcd。', file=sys.stderr)
return 1
token = login.stdout.strip()
env = dict(os.environ, BAO_TOKEN=token)
try:
for play in ['verify.yml', 'site.yml']:
result = subprocess.run(
['/home/panxiao81/.local/bin/ansible-playbook', play],
cwd=ROOT / 'ansible', env=env, timeout=750,
)
if result.returncode:
return result.returncode
Path('/var/lib/homelab-etcd-controller/last-success').touch()
return 0
finally:
request = urllib.request.Request(
'https://bao.ad.ddupan.top:8200/v1/auth/token/revoke-self',
data=b'{}', headers={'X-Vault-Token': token}, method='POST',
)
try:
with urllib.request.urlopen(request, timeout=15):
pass
except Exception:
print('短期 token 撤销未确认,将由 TTL 自动失效。', file=sys.stderr)
if __name__ == '__main__':
raise SystemExit(main())
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""从当前 Bao 登录会话向子进程传递凭据,不写临时秘密文件或输出秘密。"""
import json
import os
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parent
def main():
if len(sys.argv) < 3 or sys.argv[1] not in ('terraform', 'ansible'):
raise SystemExit('用法:python3 run.py terraform <args> | ansible <playbook> [args]')
env = dict(os.environ)
env.setdefault('BAO_ADDR', 'https://bao.ad.ddupan.top:8200')
token = env.get('BAO_TOKEN') or env.get('VAULT_TOKEN')
if not token:
token = Path('~/.vault-token').expanduser().read_text().strip()
env['BAO_TOKEN'] = env['VAULT_TOKEN'] = token
if sys.argv[1] == 'terraform':
response = subprocess.run(
['bao', 'kv', 'get', '-format=json', 'kv/k8s/seaweedfs-s3'],
env=env, capture_output=True, text=True,
)
if response.returncode:
raise SystemExit('读取 tfstate 受限身份失败;请检查 Bao 登录和授权。')
config = json.loads(response.stdout)['data']['data']['seaweedfs_s3_config']
config = json.loads(config) if isinstance(config, str) else config
identities = [i for i in config['identities'] if i['name'] == 'terraform']
if len(identities) != 1 or len(identities[0]['credentials']) != 1:
raise SystemExit('tfstate 身份不唯一,拒绝猜测凭据。')
credential = identities[0]['credentials'][0]
env['AWS_ACCESS_KEY_ID'] = credential['accessKey']
env['AWS_SECRET_ACCESS_KEY'] = credential['secretKey']
command, cwd = ['terraform', *sys.argv[2:]], ROOT / 'terraform'
else:
command, cwd = ['ansible-playbook', *sys.argv[2:]], ROOT / 'ansible'
raise SystemExit(subprocess.run(command, cwd=cwd, env=env).returncode)
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/vault" {
version = "4.8.0"
constraints = "~> 4.0"
hashes = [
"h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=",
"zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6",
"zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136",
"zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25",
"zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf",
"zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937",
"zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c",
"zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c",
"zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98",
"zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952",
"zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83",
]
}
+15
View File
@@ -0,0 +1,15 @@
# 与既有服务复用受限 tfstate 身份,使用独立对象与原生锁;不复用 Bao 的 state。
terraform {
backend "s3" {
bucket = "tfstate"
key = "etcd/terraform.tfstate"
endpoints = { s3 = "https://s3.ad.ddupan.top" }
region = "us-east-1"
use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
use_lockfile = true
}
}
+93
View File
@@ -0,0 +1,93 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "~> 4.0"
}
}
}
provider "vault" {
address = var.bao_address
}
variable "bao_address" {
type = string
default = "https://bao.ad.ddupan.top:8200"
}
variable "pki_mount" {
type = string
default = "pki"
}
variable "kv_mount" {
type = string
default = "kv"
}
variable "member_names" {
type = list(string)
default = ["etcd-laptop", "etcd-pve1", "etcd-pve2"]
}
variable "consumer_names" {
type = set(string)
default = ["patroni-pg-prod"]
}
# 仅管理既有 PKI 下的新 role/policy,不纳管 CA 私钥、mount 或秘密值。
locals {
certificate_roles = {
server = { names = var.member_names, server = true, client = false, ips = true }
peer = { names = concat(var.member_names, ["homelab-etcd-peer"]), server = true, client = true, ips = true }
admin = { names = ["root"], server = false, client = true, ips = false }
gateway = { names = var.member_names, server = false, client = true, ips = false }
client = { names = [for name in var.consumer_names : "etcd-${name}"], server = false, client = true, ips = false }
}
}
resource "vault_pki_secret_backend_role" "etcd" {
for_each = local.certificate_roles
backend = var.pki_mount
name = "homelab-etcd-${each.key}"
allowed_domains = each.value.names
allow_bare_domains = true
allow_subdomains = false
allow_glob_domains = false
allow_any_name = false
allow_localhost = false
allow_wildcard_certificates = false
allow_ip_sans = each.value.ips
server_flag = each.value.server
client_flag = each.value.client
key_type = "ec"
key_bits = 256
ttl = 5184000
max_ttl = 5184000
require_cn = !contains(["gateway", "client"], each.key)
use_csr_common_name = true
use_csr_sans = true
}
resource "vault_policy" "etcd_provisioner" {
name = "homelab-etcd-provisioner"
policy = <<-EOT
path "${var.pki_mount}/sign/homelab-etcd-*" {
capabilities = ["update"]
}
path "${var.kv_mount}/metadata/infra/etcd/consumers/*" {
capabilities = ["read"]
}
path "${var.kv_mount}/data/infra/etcd/consumers/*" {
capabilities = ["create", "read", "update"]
}
EOT
}
# 身份绑定沿用既有控制端认证方式,创建 policy 不自动授权任何身份。
resource "vault_policy" "etcd_consumer" {
for_each = var.consumer_names
name = "homelab-etcd-${each.key}"
policy = <<-EOT
path "${var.kv_mount}/data/infra/etcd/consumers/${each.key}" {
capabilities = ["read"]
}
EOT
}
+28
View File
@@ -0,0 +1,28 @@
# 独立挂载:不接管全局认证,不保存长期 token,也不授权 KV 消费者秘密读取。
data "vault_generic_secret" "etcd_ca" {
path = "${var.pki_mount}/cert/ca"
}
resource "vault_auth_backend" "etcd_renewal" {
type = "cert"
path = "homelab-etcd-renewal"
}
resource "vault_policy" "etcd_renewal" {
name = "homelab-etcd-renewal"
policy = join("\n", concat([
for kind in ["server", "peer", "admin", "gateway"] :
"path \"${var.pki_mount}/sign/homelab-etcd-${kind}\" { capabilities = [\"update\"] }"
], ["path \"auth/token/revoke-self\" { capabilities = [\"update\"] }"]))
}
resource "vault_cert_auth_backend_role" "etcd_renewal" {
backend = vault_auth_backend.etcd_renewal.path
name = "etcd-laptop"
certificate = data.vault_generic_secret.etcd_ca.data["certificate"]
allowed_dns_sans = ["etcd-laptop"]
token_policies = [vault_policy.etcd_renewal.name]
token_no_default_policy = true
token_ttl = 600
token_max_ttl = 900
}
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""临时三成员 mTLS/RBAC 集成测试;仅绑定 loopback,不访问生产 Bao。"""
import base64
import json
import os
from pathlib import Path
import re
import shutil
import ssl
import subprocess
import tempfile
import threading
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import jinja2
import yaml
ROOT = Path(__file__).resolve().parents[1]
BIN = Path(os.environ.get('ETCD_TEST_BIN', '/tmp/etcd-v3.7.2-linux-amd64'))
def run(argv, **kwargs):
return subprocess.run([str(x) for x in argv], capture_output=True, text=True, check=True, **kwargs)
class FakeBao(BaseHTTPRequestHandler):
records = {}
versions = {}
writes = 0
deny = False
def log_message(self, *_):
pass
def respond(self, status, body):
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(body).encode())
def do_GET(self):
if self.deny:
return self.respond(403, {'errors': ['permission denied']})
name = self.path.rsplit('/', 1)[-1]
if '/metadata/' in self.path and name in self.versions:
return self.respond(200, {'data': {'current_version': self.versions[name]}})
if '/data/' in self.path and name in self.records:
return self.respond(200, {'data': {'data': self.records[name]}})
self.respond(404, {'errors': []})
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
name = self.path.rsplit('/', 1)[-1]
if body['options']['cas'] != 0 or name in self.versions:
return self.respond(400, {'errors': ['CAS mismatch']})
self.records[name] = body['data']
self.versions[name] = 1
type(self).writes += 1
self.respond(200, {'data': {'version': 1}})
def main():
for key in ('HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'):
os.environ.pop(key, None)
os.environ['NO_PROXY'] = '*'
processes = []
handles = []
server = None
with tempfile.TemporaryDirectory(prefix='shared-etcd-test-') as work:
w = Path(work)
w.chmod(0o700)
try:
run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', w/'ca.key',
'-out', w/'ca.crt', '-days', '1', '-subj', '/CN=isolated-test-ca'])
def cert(name, cn, eku, ip=None):
run(['openssl', 'req', '-new', '-newkey', 'rsa:2048', '-nodes', '-keyout', w/f'{name}.key',
'-out', w/f'{name}.csr', '-subj', f'/CN={cn}' if cn else '/'])
ext = w/f'{name}.ext'
ext.write_text(f'extendedKeyUsage={eku}\n' + (f'subjectAltName=IP:{ip},IP:127.0.0.1\n' if ip else ''))
run(['openssl', 'x509', '-req', '-in', w/f'{name}.csr', '-CA', w/'ca.crt',
'-CAkey', w/'ca.key', '-CAcreateserial', '-out', w/f'{name}.crt', '-days', '1', '-extfile', ext])
cert('admin', 'root', 'clientAuth')
cert('client', '', 'clientAuth')
cert('gateway', '', 'clientAuth')
env = jinja2.Environment(loader=jinja2.FileSystemLoader(ROOT/'ansible/roles/shared_etcd/templates'), undefined=jinja2.StrictUndefined)
env.filters['to_json'] = json.dumps
defaults = yaml.safe_load((ROOT/'ansible/roles/shared_etcd/defaults/main.yml').read_text())
names = ['test1', 'test2', 'test3']
hosts = {name: {'etcd_address': f'127.0.0.{i+2}'} for i, name in enumerate(names)}
for name in names:
d = w/name
d.mkdir()
for kind, cn, eku in [('server', name, 'serverAuth'), ('peer', 'homelab-etcd-peer', 'serverAuth,clientAuth')]:
cert(f'{name}-{kind}', cn, eku, hosts[name]['etcd_address'])
for suffix in ['crt', 'key']:
shutil.copy(w/f'{name}-{kind}.{suffix}', d/f'{kind}.{suffix}')
shutil.copy(w/'ca.crt', d/'ca.crt')
for suffix in ['crt', 'key']:
shutil.copy(w/f'gateway.{suffix}', d/f'gateway.{suffix}')
values = dict(defaults, inventory_hostname=name, groups={'etcd': names}, hostvars=hosts,
etcd_address=hosts[name]['etcd_address'], etcd_config_dir=str(d), etcd_data_dir=str(d/'data'),
etcd_client_port=22379, etcd_peer_port=22380, etcd_metrics_port=0)
config = env.get_template('etcd.yml.j2').render(**values)
# 三成员共享进程命名空间,metrics 用独立 loopback IP。
config = config.replace('http://127.0.0.1:0', f"http://{hosts[name]['etcd_address']}:22381")
(d/'config.yml').write_text(config)
handle = (d/'etcd.log').open('w')
handles.append(handle)
processes.append(subprocess.Popen([str(BIN/'etcd'), '--config-file='+str(d/'config.yml')], stdout=handle, stderr=handle))
base_env = dict(os.environ, ETCDCTL_ENDPOINTS='https://127.0.0.2:22379', ETCDCTL_CACERT=str(w/'ca.crt'),
ETCDCTL_CERT=str(w/'admin.crt'), ETCDCTL_KEY=str(w/'admin.key'), ETCDCTL_DIAL_TIMEOUT='2s', ETCDCTL_COMMAND_TIMEOUT='3s')
def ctl(*args, input=None, env=None):
return run([BIN/'etcdctl', *args], input=input, env=env or base_env)
for attempt in range(5):
try:
ctl('endpoint', 'health')
break
except subprocess.CalledProcessError:
if any(p.poll() is not None for p in processes):
for name in names:
print((w/name/'etcd.log').read_text()[-4000:])
raise RuntimeError('test etcd exited during startup')
time.sleep(0.2)
else:
print((w/'test1'/'etcd.log').read_text()[-5000:])
raise RuntimeError('test quorum did not form')
server = ThreadingHTTPServer(('127.0.0.1', 0), FakeBao)
threading.Thread(target=server.serve_forever, daemon=True).start()
inventory = w/'hosts.yml'
inventory.write_text(yaml.safe_dump({'all': {'children': {'etcd': {'hosts': {'test1': {'ansible_connection': 'local'}}}}}}))
extra = {
'ansible_become': False, 'etcd_address': '127.0.0.2', 'etcd_client_port': 22379,
'etcd_install_dir': str(BIN), 'etcd_config_dir': str(w), 'etcd_bootstrap_auth': True,
'etcd_bao_url': f'http://127.0.0.1:{server.server_port}', 'etcd_bao_token': 'isolated-test-token',
'etcd_bao_kv_mount': 'kv', 'etcd_bao_secret_base': 'infra/etcd/consumers',
'etcd_consumers': [{'name': 'patroni-pg-prod', 'prefix': '/homelab/patroni/pg-prod/'}],
}
(w/'extra.json').write_text(json.dumps(extra))
ansible_env = dict(os.environ, ANSIBLE_LOCAL_TEMP=str(w/'ansible-tmp'), ANSIBLE_NOCOLOR='1',
ANSIBLE_ROLES_PATH=str(ROOT/'ansible/roles'))
def play(name, fail=False):
r = subprocess.run(['ansible-playbook', '-i', str(inventory), str(ROOT/'ansible'/name), '-e', '@'+str(w/'extra.json')],
capture_output=True, text=True, env=ansible_env)
(w/(name+'.log')).write_text(r.stdout+r.stderr)
if fail:
assert r.returncode != 0, 'expected a fail-closed playbook error'
elif r.returncode:
# Tasks use no_log for secret data; retain useful task/line diagnostic.
print(r.stdout[-5000:]);print(r.stderr[-2000:])
raise RuntimeError(name+' failed')
return r.stdout
play('bootstrap-auth.yml')
assert re.search(r'changed=0\s', play('bootstrap-auth.yml'))
play('consumers.yml')
secret = FakeBao.records['patroni-pg-prod']['password']
assert len(secret) == 48
assert re.search(r'changed=0\s', play('consumers.yml'))
assert FakeBao.writes == 1
role = json.loads(ctl('--write-out=json', 'role', 'get', 'patroni-pg-prod').stdout)
assert role['perm'][0]['key'] == base64.b64encode(b'/homelab/patroni/pg-prod/').decode()
client_env = dict(base_env, ETCDCTL_CERT=str(w/'client.crt'), ETCDCTL_KEY=str(w/'client.key'),
ETCDCTL_USER='patroni-pg-prod', ETCDCTL_PASSWORD=secret)
ctl('put', '/homelab/patroni/pg-prod/test', 'ok', env=client_env)
try:
ctl('put', '/homelab/other/test', 'denied', env=client_env)
raise AssertionError('cross-prefix write succeeded')
except subprocess.CalledProcessError:
pass
# 模拟 Patroni 的 gateway 协议,证明 mTLS + username/password 可组合使用。
ctx = ssl.create_default_context(cafile=str(w/'ca.crt'))
ctx.load_cert_chain(w/'client.crt', w/'client.key')
body = json.dumps({'name':'patroni-pg-prod', 'password':secret}).encode()
req = urllib.request.Request('https://127.0.0.2:22379/v3/auth/authenticate', data=body, headers={'Content-Type':'application/json'})
with urllib.request.urlopen(req, context=ctx) as response:
token = json.load(response)['token']
gateway_body = json.dumps({'key': base64.b64encode(b'/homelab/patroni/pg-prod/gateway').decode(),
'value': base64.b64encode(b'ok').decode()}).encode()
req = urllib.request.Request('https://127.0.0.2:22379/v3/kv/put', data=gateway_body,
headers={'Content-Type': 'application/json', 'Authorization': token})
with urllib.request.urlopen(req, context=ctx) as response:
assert response.status == 200
# 对已有用户,秘密值被意外覆盖也必须失败,而不是悄悄改 etcd 密码。
FakeBao.records['patroni-pg-prod']['password'] = 'x' * 48
play('consumers.yml', fail=True)
FakeBao.records['patroni-pg-prod']['password'] = secret
FakeBao.deny = True
play('consumers.yml', fail=True)
FakeBao.deny = False
saved = FakeBao.records.pop('patroni-pg-prod')
play('consumers.yml', fail=True) # metadata 存在、data 已删除
FakeBao.versions.clear()
play('consumers.yml', fail=True) # 用户存在但 metadata 丢失
assert FakeBao.writes == 1
FakeBao.records['patroni-pg-prod'] = saved
FakeBao.versions['patroni-pg-prod'] = 1
ctl('snapshot', 'save', str(w/'snapshot.db'))
snapshot = json.loads(run([BIN/'etcdutl', '--write-out=json', 'snapshot', 'status', w/'snapshot.db']).stdout)
assert snapshot['totalKey'] > 0
run([BIN/'etcdutl', 'snapshot', 'restore', w/'snapshot.db', '--data-dir='+str(w/'restored')])
rss = []
for process in processes:
match = re.search(r'^VmRSS:\s+(\d+)', Path(f'/proc/{process.pid}/status').read_text(), re.M)
rss.append(round(int(match[1]) / 1024, 1))
processes[2].terminate();processes[2].wait(timeout=10)
ctl('put', '/homelab/patroni/pg-prod/after-member-loss', 'ok', env=client_env)
print('PASS: three-member mTLS, auth bootstrap/idempotence, Bao create-once/fail-closed, prefix isolation, gateway auth, snapshot/restore, one-member loss')
print('Idle test member RSS MiB (not a production capacity result):', rss)
finally:
if server:
server.shutdown();server.server_close()
for process in processes:
if process.poll() is None:
process.terminate()
try: process.wait(timeout=10)
except subprocess.TimeoutExpired: process.kill();process.wait()
for handle in handles:
handle.close()
if __name__ == '__main__':
main()
+71
View File
@@ -0,0 +1,71 @@
"""续签调度失败关闭测试;不会连接真实 Bao 或运行 Ansible。"""
import importlib.util
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, Mock
spec = importlib.util.spec_from_file_location('renew', Path(__file__).parents[1] / 'controller/renew.py')
renew = importlib.util.module_from_spec(spec)
spec.loader.exec_module(renew)
class RenewalTests(unittest.TestCase):
def execute(self, results):
with tempfile.TemporaryDirectory() as directory:
lock = Path(directory) / 'lock'
success = Path(directory) / 'last-success'
original_open = open
response = Mock()
response.__enter__ = Mock(return_value=response)
response.__exit__ = Mock(return_value=False)
with patch('builtins.open', side_effect=lambda *_: original_open(lock, 'a')), \
patch.object(renew.subprocess, 'run', side_effect=results) as run, \
patch.object(renew.urllib.request, 'urlopen', return_value=response) as revoke, \
patch.object(renew, 'Path', return_value=success):
rc = renew.main()
return rc, run.call_args_list, revoke.call_count, success.exists()
def test_login_failure_never_changes_members(self):
rc, calls, revokes, success = self.execute([subprocess.CompletedProcess([], 1, '', '')])
self.assertEqual(rc, 1)
self.assertEqual(len(calls), 1)
self.assertEqual(revokes, 0)
self.assertFalse(success)
def test_unhealthy_cluster_never_runs_site(self):
rc, calls, revokes, success = self.execute([
subprocess.CompletedProcess([], 0, 'test-only-token'),
subprocess.CompletedProcess([], 2),
])
self.assertEqual(rc, 2)
self.assertEqual(calls[1].args[0][-1], 'verify.yml')
self.assertEqual(len(calls), 2)
self.assertEqual(revokes, 1)
self.assertFalse(success)
def test_success_checks_then_converges_and_revokes(self):
rc, calls, revokes, success = self.execute([
subprocess.CompletedProcess([], 0, 'test-only-token'),
subprocess.CompletedProcess([], 0),
subprocess.CompletedProcess([], 0),
])
self.assertEqual(rc, 0)
self.assertEqual([c.args[0][-1] for c in calls[1:]], ['verify.yml', 'site.yml'])
self.assertEqual(revokes, 1)
self.assertTrue(success)
def test_site_failure_is_not_success(self):
rc, calls, revokes, success = self.execute([
subprocess.CompletedProcess([], 0, 'test-only-token'),
subprocess.CompletedProcess([], 0),
subprocess.CompletedProcess([], 2),
])
self.assertEqual(rc, 2)
self.assertEqual(revokes, 1)
self.assertFalse(success)
if __name__ == '__main__':
unittest.main()