diff --git a/apps/authelia/clients/incus.yaml b/apps/authelia/clients/incus.yaml new file mode 100644 index 0000000..7ac6413 --- /dev/null +++ b/apps/authelia/clients/incus.yaml @@ -0,0 +1,30 @@ +# 独立增量声明:全量 values.yaml 尚未覆盖所有线上客户端,不能用它覆盖 release。 +authorization_policies: + incus_admin: + default_policy: deny + rules: + - policy: two_factor + subject: user:panxiao81 +clients: + - client_id: incus + client_name: Incus + public: true + authorization_policy: incus_admin + require_pkce: true + pkce_challenge_method: S256 + redirect_uris: + - https://incus.ad.ddupan.top/oidc/callback + audience: + - https://incus.ad.ddupan.top + scopes: + - openid + - offline_access + response_types: + - code + grant_types: + - authorization_code + - refresh_token + - urn:ietf:params:oauth:grant-type:device_code + access_token_signed_response_alg: RS256 + userinfo_signed_response_alg: none + token_endpoint_auth_method: none diff --git a/infrastructure/dns/records.yml b/infrastructure/dns/records.yml index c7d3dd9..3a9e119 100644 --- a/infrastructure/dns/records.yml +++ b/infrastructure/dns/records.yml @@ -6,6 +6,7 @@ homelab_dns: # Samba remains authoritative for the AD zone. Only these explicitly listed # RRsets are reconciled; Samba-generated AD/Kerberos records are untouched. records: + - { zone: ad.ddupan.top, name: incus, type: A, values: [192.168.10.127] } - { zone: ad.ddupan.top, name: bao, type: A, values: [192.168.10.8] } - { zone: ad.ddupan.top, name: pve1, type: A, values: [192.168.10.4] } - { zone: ad.ddupan.top, name: pve2, type: A, values: [192.168.10.7] } diff --git a/infrastructure/incus/README.md b/infrastructure/incus/README.md new file mode 100644 index 0000000..531492c --- /dev/null +++ b/infrastructure/incus/README.md @@ -0,0 +1,145 @@ +# laptop Incus + +Ansible 管理 Ubuntu 24.04 amd64 上的 Zabbly stable APT 源、公钥、固定包版本和 +Incus 本地服务。当前版本为 `7.5.1`,完整 Debian 版本见 +`ansible/group_vars/incus_hosts.yml`。使用系统包,不使用 snap。 + +```bash +ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus ansible-playbook -i infrastructure/incus/ansible/inventory/hosts.yml infrastructure/incus/ansible/site.yml --syntax-check +ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus ansible-playbook -i infrastructure/incus/ansible/inventory/hosts.yml infrastructure/incus/ansible/site.yml --check --diff +ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus ansible-playbook -i infrastructure/incus/ansible/inventory/hosts.yml infrastructure/incus/ansible/site.yml +``` + +执行者需有免密 sudo。首次机器没有包索引时,check 模式不能验证待安装包的可用性; +实际执行会刷新该源并校验包签名。重跑应 `changed=0`。 + +## 管理边界 + +- `site.yml` 管安装;`access.yml` 管 Web UI/OIDC 入口。不执行 `incus admin init`,不创建存储池、profile、实例或受管网络。 +- 保留 libvirt VM、k3s、现有 bridge、路由和防火墙配置;不引入 OVN/SDN/LB。 +- HTTPS listener 为 `192.168.10.127:8443`,域名入口经现有 Envoy;不新增 `incus-admin` 成员。本地 `sudo incus` 保留为恢复入口。 +- etcd 和共享 PostgreSQL 由各自目录管理;此目录不迁移它们。 +- 不自动删除旧实例或存储;历史 `data/incus` 已由维护者另行授权删除,非本 playbook 行为。 + +## 版本与公钥维护 + +公钥来自 Zabbly,主指纹为 `4EFC590696CB15B87C73A3AD82CC8797C838DCFD`, +与[上游安装说明](https://github.com/zabbly/incus)核对后随配置保存。 +更新密钥时先核对上游指纹。版本由 APT preferences 固定;升级需修改变量, +审阅 `--check --diff` 后执行,不自动降级。 +上游 stable 源未承诺永久保留旧构建,长期离线重建需另行保存包及依赖。 + +## 验证与故障入口 + +```bash +sudo incus version +sudo incus list local: +sudo incus storage list local: +sudo incus network list local: +systemctl status incus.service incus.socket +sudo journalctl -u incus.service -n 80 --no-pager +``` + +网络列表可能展示宿主已有的非受管接口,不代表 Incus 创建了网络。 +安装验收不等于实例运行验收;存储、实例备份恢复方案留待资源初始化时确定。 +共享知识入口:`homelab-wiki/services/incus.md`(随本次变更同步)。 + +## Web UI 与 Authelia + +入口:`https://incus.ad.ddupan.top`,选择 **Login with SSO**,使用 `panxiao81` +并完成 MFA。Authelia 专属 policy 默认 deny,仅此账号可取得 Incus token。 +该客户端登录者具有 Incus 完整管理权限,不根据当前 AD 组放权。 + +CLI 可使用 `incus remote add laptop https://incus.ad.ddupan.top --auth-type=oidc`, +按设备码流程在浏览器中完成同一登录;token 由客户端保存在本机,不写入 Git。 + +声明分工: + +- `apps/authelia/clients/incus.yaml`:单用户 MFA 准入、public client、PKCE S256、 + 签名 access token、唯一 audience、浏览器回调和 device/refresh grants。 +- `ansible/group_vars/incus_hosts.yml`:Incus OIDC issuer/client/audience/scopes 和 listener。 +- `ansible/templates/gateway.yaml.j2`:独立 namespace、Service/EndpointSlice、HTTPRoute、 + BackendTLSPolicy。入口复用现有通配符证书,后端以 Incus 的公共 server.crt 验证 + `laptop` SAN,不跳过 TLS 验证。公共证书由 playbook 读取,不复制私钥。 +- `infrastructure/dns/records.yml`:唯一 DNS 声明;`ansible/dns.yml` 只协调 Incus 记录。 + +入口资源由此 Ansible playbook 管理,尚未交由 Flux。共享 Gateway 的后端 TLS +兼容配置由 Flux 管理:EnvoyProxy `eg` 允许 TLS 1.2–1.3,以连接要求 TLS 1.3 +的 Incus;见 [PR #164](https://git.ddupan.top/panxiao81/homelab-infra/pulls/164)。 +此补丁不升级网关、不改变前端证书或其他路由。 +Authelia 当前不是 Flux 受管 release。协调脚本先读取 live Helm values, +仅合并 Incus client/policy,固定 chart 0.11.6 渲染后部署;保留 Hydra/Backstage 等 +现有配置和秘密,禁止以滞后的 `apps/authelia/values.yaml` 全量覆盖线上 release。 +临时 values 存在 0700 目录、0600 文件中并自动清理,部署前检查 release revision, +避免覆盖准备期间的并发 Helm 变更;仍应避免同时升级该 release。 + +```bash +# 先 site.yml 安装 UI,再接入;均先 --check --diff 再移除这两个参数执行。 +ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus ansible-playbook -i infrastructure/incus/ansible/inventory/hosts.yml infrastructure/incus/ansible/access.yml --check --diff + +# DNS 使用既有 Samba inventory/collection,仅修改 Incus 的 RRset。 +cd infrastructure/samba-ad/ansible +ANSIBLE_CONFIG=ansible.cfg ANSIBLE_COLLECTIONS_PATH=collections ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus-dns ansible-playbook -i inventory/hosts.yml ../../incus/ansible/dns.yml --check --diff +``` + +执行接入需要本机 Python3/PyYAML、Helm、kubectl 及现有集群管理凭据;DNS 另需 +既有 Samba Ansible vault 与 SSH 权限。首次入口 namespace 不存在时 check 使用 +客户端 dry-run;存在后使用 kubectl diff。后续实际重跑要求 `changed=0`。 + +### 故障与迁移 + +- Web 入口和 OIDC 依赖 k3s/Envoy/Authelia;它们不可用时使用宿主 `sudo incus`, + Incus daemon 与实例生命周期不依赖网页登录。 +- `8443` 直连仍由 Incus 原生认证保护,但只注册域名 443 的 OIDC callback, + 浏览器登录应从正式域名进入。网关不透传客户端 TLS 证书;远程主要使用 OIDC。 +- 更换宿主 Incus 证书后重跑 `access.yml`,同步网关公共信任证书。 +- 未来切换 IdM:更新 issuer/client/audience/scopes 和新 provider 的准入策略, + 验证浏览器及 CLI 后再停用 Authelia client;本次不引入永久组模型。 +- 移除用户准入不会立即撤销已签发的 JWT;紧急撤权需同时处理现有 token/会话。 +- 回退入口时先撤 HTTPRoute,再关闭 `core.https_address`;保留本地管理和现有实例数据。 + +验证包括 DNS、TLS、route conditions、匿名 API 拒绝、PKCE/回调和设备码授权发起。 +2026-09-25 维护者确认 `panxiao81` 完成 MFA 并成功返回 Incus 控制台。 +安装、接入、DNS playbook 重跑均 `changed=0`;后续基础容器验收见下节。 +未验证 Web 实例控制台或 Ayatori 应用数据路径。 + +## Ayatori 基础容器 + +`terraform/` 只管理 `ayatori-dev`、`ayatori-prod` 两个 Ubuntu 24.04 非特权 LXC, +以及专属 ZFS 存储池 `ayatori`(`data/incus-ayatori`)。每个容器上限 2 CPU、 +2 GiB 内存、20 GiB rootfs,禁用容器 swap,自动启动。没有安装 Ayatori、k0s、 +kube-apiserver、controller 或数据库;准备 Prod 空容器不代表生产服务已上线。 + +网络使用既有 `br0` 的 DHCP,固定 MAC 分别为 `02:16:3e:aa:00:01`、 +`02:16:3e:aa:00:02`。地址不是静态分配,不发布 DNS;后续固定服务地址前应建立 +DHCP reservation 或核对地址池后配置静态地址。 + +初始账号 `panxiao81`,使用 `ansible/files/panxiao81.pub` 公钥登录,可 sudo; +密码登录关闭。宿主也可使用 `incus exec local:ayatori-dev -- bash`。 +provider 通过本地 Unix socket 操作,执行账号须有 socket 权限。 + +```bash +terraform -chdir=infrastructure/incus/terraform init +terraform -chdir=infrastructure/incus/terraform plan -out=containers.tfplan +terraform -chdir=infrastructure/incus/terraform apply -parallelism=1 containers.tfplan +ANSIBLE_LOCAL_TEMP=/tmp/ansible-incus ansible-playbook -i infrastructure/incus/ansible/inventory/hosts.yml infrastructure/incus/ansible/containers.yml +terraform -chdir=infrastructure/incus/terraform output containers +terraform -chdir=infrastructure/incus/terraform plan -detailed-exitcode +``` + +provider 固定 1.2.0 并保留 lockfile,镜像固定 Ubuntu 24.04 cloud 构建指纹。 +上游滚动镜像不保证永久保存,重建前需确认本机镜像缓存或归档可用。 +cloud-init 用户设置主要在首次启动执行,修改声明不能替代后续用户/密钥轮换流程。 + +当前 state 位于 `terraform/terraform.tfstate`(Git 忽略),是本地 backend; +现有 Bao 会话无法读取远端 tfstate 凭据,因此尚未启用远端 backend。 +后续迁移须使用 `terraform init -migrate-state` 保留资源归属,不创建第二份独立 state。 +实例和池启用 `prevent_destroy`;删除需显式审阅,不能通过移走整个资源块绕过保护。 +当前为空系统,丢失后可重建;承载持久数据前须补离机备份与恢复验收。 + +首次从同一远端并行创建实例时,Incus 7.5.1 曾出现 simplestreams 缓存目录 +`mkdir ... file exists` 竞争;按上面的串行 apply 执行。失败后先重新 plan, +保留已成功创建的实例,不清理或销毁其资源。 + +当前 DHCP 地址:Dev `192.168.10.131`,Prod `192.168.10.132`(2026-09-25)。 +`ansible/containers.yml` 通过本机 Incus exec 幂等安装并启用 SSH server。 diff --git a/infrastructure/incus/ansible/access.yml b/infrastructure/incus/ansible/access.yml new file mode 100644 index 0000000..b76205c --- /dev/null +++ b/infrastructure/incus/ansible/access.yml @@ -0,0 +1,49 @@ +--- +- name: 接入 Incus Web UI 与 Authelia + hosts: incus_hosts + become: true + gather_facts: false + tasks: + - name: 增量协调 Authelia 的 Incus 客户端 + become: false + ansible.builtin.command: + argv: "{{ ['python3', playbook_dir ~ '/../scripts/reconcile_authelia.py', '--desired', playbook_dir ~ '/../../../apps/authelia/clients/incus.yaml'] + (['--check'] if ansible_check_mode else []) }}" + register: incus_authelia + changed_when: "'changed=true' in incus_authelia.stdout" + check_mode: false + + - name: 查询当前 Incus 服务配置 + ansible.builtin.command: incus query /1.0 + register: incus_server + changed_when: false + check_mode: false + no_log: true + + - name: 协调 OIDC 与 LAN HTTPS listener + ansible.builtin.command: + argv: [incus, config, set, "{{ item.key }}={{ item.value }}"] + loop: "{{ incus_server_config | dict2items }}" + when: (incus_server.stdout | from_json).config.get(item.key, '') != item.value + changed_when: true + + - name: 读取 Incus 公共证书用于网关后端验证 + ansible.builtin.slurp: + src: /var/lib/incus/server.crt + register: incus_backend_certificate + + - name: 检查入口 namespace 是否已存在 + become: false + ansible.builtin.command: kubectl get namespace incus --ignore-not-found -o name + register: incus_namespace + changed_when: false + check_mode: false + + - name: 渲染并预览或应用专属入口资源 + become: false + ansible.builtin.command: + argv: "{{ (['kubectl', 'apply', '--dry-run=client', '-f', '-'] if incus_namespace.stdout == '' else ['kubectl', 'diff', '-f', '-']) if ansible_check_mode else ['kubectl', 'apply', '-f', '-'] }}" + stdin: "{{ lookup('template', 'gateway.yaml.j2') }}" + register: incus_gateway + check_mode: false + changed_when: "(incus_gateway.rc == 1 or incus_namespace.stdout == '') if ansible_check_mode else ('created' in incus_gateway.stdout or 'configured' in incus_gateway.stdout)" + failed_when: "incus_gateway.rc not in ([0, 1] if ansible_check_mode else [0])" diff --git a/infrastructure/incus/ansible/containers.yml b/infrastructure/incus/ansible/containers.yml new file mode 100644 index 0000000..dc55d01 --- /dev/null +++ b/infrastructure/incus/ansible/containers.yml @@ -0,0 +1,10 @@ +--- +- name: 准备 Ayatori 基础容器的 SSH 入口 + hosts: incus_hosts + gather_facts: false + tasks: + - name: 按容器协调 SSH + ansible.builtin.include_tasks: tasks/container-ssh.yml + loop: [ayatori-dev, ayatori-prod] + loop_control: + loop_var: incus_container diff --git a/infrastructure/incus/ansible/dns.yml b/infrastructure/incus/ansible/dns.yml new file mode 100644 index 0000000..639d26e --- /dev/null +++ b/infrastructure/incus/ansible/dns.yml @@ -0,0 +1,27 @@ +--- +- name: 仅协调 Incus 的 AD DNS 记录 + hosts: samba_dc + become: true + gather_facts: false + vars_files: + - ../../dns/records.yml + - ../../samba-ad/ansible/group_vars/all/vars.yml + tasks: + - name: 从共享清单选择 Incus RRset + ansible.builtin.set_fact: + incus_dns_records: "{{ homelab_dns.samba.records | selectattr('name', 'equalto', 'incus') | selectattr('zone', 'equalto', 'ad.ddupan.top') | list }}" + - name: 确认只有一个受管 A 记录 + ansible.builtin.assert: + that: + - incus_dns_records | length == 1 + - incus_dns_records[0].type == 'A' + - name: 协调 Incus A 记录 + ddupan.homelab.samba_dns_record: + server: "{{ samba_ad_dc_ip }}" + zone: "{{ item.zone }}" + name: "{{ item.name }}" + type: "{{ item.type }}" + values: "{{ item['values'] }}" + state: present + exact: true + loop: "{{ incus_dns_records }}" diff --git a/infrastructure/incus/ansible/files/panxiao81.pub b/infrastructure/incus/ansible/files/panxiao81.pub new file mode 100644 index 0000000..a6fbc6e --- /dev/null +++ b/infrastructure/incus/ansible/files/panxiao81.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOLvzIxZhVRd9wEFWR/uCOx7b4HQEdPDiZd8LCN7Hics panxiao81@laptop diff --git a/infrastructure/incus/ansible/files/zabbly.asc b/infrastructure/incus/ansible/files/zabbly.asc new file mode 100644 index 0000000..97f79e6 --- /dev/null +++ b/infrastructure/incus/ansible/files/zabbly.asc @@ -0,0 +1,41 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=tHYp +-----END PGP PUBLIC KEY BLOCK----- diff --git a/infrastructure/incus/ansible/group_vars/incus_hosts.yml b/infrastructure/incus/ansible/group_vars/incus_hosts.yml new file mode 100644 index 0000000..6775055 --- /dev/null +++ b/infrastructure/incus/ansible/group_vars/incus_hosts.yml @@ -0,0 +1,16 @@ +--- +# 显式升级版本,避免例行重跑意外升级虚拟化服务。 +incus_package_version: '1:7.5.1-ubuntu24.04-202609250207' +incus_packages: + - incus + - incus-base + - incus-client + - incus-ui-canonical + +# 先完成 IdP 准入限制,再配置 OIDC,最后开放 listener。 +incus_server_config: + oidc.issuer: https://auth.ddupan.top + oidc.client.id: incus + oidc.audience: https://incus.ad.ddupan.top + oidc.scopes: openid,offline_access + core.https_address: 192.168.10.127:8443 diff --git a/infrastructure/incus/ansible/inventory/hosts.yml b/infrastructure/incus/ansible/inventory/hosts.yml new file mode 100644 index 0000000..94e963b --- /dev/null +++ b/infrastructure/incus/ansible/inventory/hosts.yml @@ -0,0 +1,7 @@ +all: + children: + incus_hosts: + hosts: + laptop: + ansible_connection: local + ansible_python_interpreter: /usr/bin/python3 diff --git a/infrastructure/incus/ansible/site.yml b/infrastructure/incus/ansible/site.yml new file mode 100644 index 0000000..41c88e9 --- /dev/null +++ b/infrastructure/incus/ansible/site.yml @@ -0,0 +1,114 @@ +--- +- name: 安装 laptop 的 Incus 基础服务 + hosts: incus_hosts + become: true + gather_facts: true + tasks: + - name: 限定已验证的平台 + ansible.builtin.assert: + that: + - ansible_facts['distribution'] == 'Ubuntu' + - ansible_facts['distribution_release'] == 'noble' + - ansible_facts['architecture'] == 'x86_64' + fail_msg: 当前包版本只针对 Ubuntu 24.04 amd64 验证。 + + - name: 创建 APT 公钥目录 + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + owner: root + group: root + mode: '0755' + + - name: 安装已核对指纹的 Zabbly 公钥 + ansible.builtin.copy: + src: zabbly.asc + dest: /etc/apt/keyrings/zabbly.asc + owner: root + group: root + mode: '0644' + register: incus_key + + - name: 声明 Zabbly stable 软件源 + ansible.builtin.copy: + content: | + Enabled: yes + Types: deb + URIs: https://pkgs.zabbly.com/incus/stable + Suites: noble + Components: main + Architectures: amd64 + Signed-By: /etc/apt/keyrings/zabbly.asc + dest: /etc/apt/sources.list.d/zabbly-incus-stable.sources + owner: root + group: root + mode: '0644' + register: incus_source + + # 防止系统自动更新绕开版本声明;显式改版本后重跑才升级。 + - name: 固定 Incus 包版本 + ansible.builtin.copy: + content: | + Package: {{ incus_packages | join(' ') }} + Pin: version {{ incus_package_version }} + Pin-Priority: 1000 + dest: /etc/apt/preferences.d/incus + owner: root + group: root + mode: '0644' + + # 只刷新本组件源,避免其他服务仓库故障阻塞安装。 + - name: 刷新 Incus 包索引 + ansible.builtin.command: + argv: + - apt-get + - update + - -o + - Dir::Etc::sourcelist=sources.list.d/zabbly-incus-stable.sources + - -o + - Dir::Etc::sourceparts=- + - -o + - APT::Get::List-Cleanup=0 + - -o + - APT::Update::Error-Mode=any + changed_when: false + register: incus_refresh + retries: 3 + delay: 5 + until: incus_refresh.rc == 0 + when: not ansible_check_mode + + - name: 安装固定版本且禁止移除既有包 + ansible.builtin.apt: + name: "{{ incus_packages | map('regex_replace', '$', '=' ~ incus_package_version) | list }}" + state: present + install_recommends: false + fail_on_autoremove: true + lock_timeout: 120 + environment: + # 不让 needrestart 顺带重启 k3s、libvirt 等无关服务。 + NEEDRESTART_MODE: l + register: incus_install + retries: 3 + delay: 5 + until: incus_install is succeeded + # check 模式不会创建新源,APT 无法解析只在新源存在的版本。 + when: not (ansible_check_mode and (incus_source.changed or incus_key.changed)) + + - name: 提示首次 check 的包验证边界 + ansible.builtin.debug: + msg: 软件源或公钥尚待写入,本次仅预览仓库配置;安装后须重跑 check 和幂等验证。 + when: ansible_check_mode and (incus_source.changed or incus_key.changed) + + - name: 启用 Incus 本地 socket + ansible.builtin.systemd_service: + name: incus.socket + enabled: true + state: started + when: not ansible_check_mode + + - name: 启动 Incus 服务 + ansible.builtin.systemd_service: + name: incus.service + state: started + when: not ansible_check_mode diff --git a/infrastructure/incus/ansible/tasks/container-ssh.yml b/infrastructure/incus/ansible/tasks/container-ssh.yml new file mode 100644 index 0000000..32a0e51 --- /dev/null +++ b/infrastructure/incus/ansible/tasks/container-ssh.yml @@ -0,0 +1,87 @@ +--- +- name: 等待首次用户初始化 + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --, cloud-init, status, --wait] + changed_when: false + +- name: 检查 SSH server 是否安装 + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --, dpkg-query, -W, '-f=${Status}', openssh-server] + register: container_ssh_package + changed_when: false + failed_when: container_ssh_package.rc not in [0, 1] + +- name: 刷新容器包索引 + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --, apt-get, -o, Acquire::Retries=3, update] + when: "'install ok installed' not in container_ssh_package.stdout" + changed_when: true + register: container_apt_update + retries: 3 + delay: 5 + until: container_apt_update.rc == 0 + +- name: 安装 SSH server + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --env, DEBIAN_FRONTEND=noninteractive, --, apt-get, -o, Acquire::Retries=3, install, -y, --no-install-recommends, openssh-server] + when: "'install ok installed' not in container_ssh_package.stdout" + changed_when: true + register: container_apt_install + retries: 3 + delay: 5 + until: container_apt_install.rc == 0 + +# cloud-init 在未安装 sshd 时预先生成了只有 PasswordAuthentication 的配置; +# OpenSSH 默认 UsePAM=no 会拒绝锁定密码的公钥账号,显式采用 Ubuntu 的 PAM 模式。 +- name: 读取 SSH 配置 + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --, cat, /etc/ssh/sshd_config] + register: container_sshd_config + changed_when: false + +- name: 声明仅公钥 SSH 与 PAM 账号检查 + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --, tee, /etc/ssh/sshd_config] + stdin: "{{ container_sshd_desired }}" + vars: + container_sshd_desired: | + UsePAM yes + PubkeyAuthentication yes + PasswordAuthentication no + KbdInteractiveAuthentication no + PermitRootLogin no + Subsystem sftp internal-sftp + when: container_sshd_config.stdout | trim != container_sshd_desired | trim + register: container_sshd_write + changed_when: true + +- name: 校验 SSH 配置 + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --, /usr/sbin/sshd, -t] + changed_when: false + +- name: 更新运行中的 SSH 配置 + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --, systemctl, restart, ssh] + when: container_sshd_write is changed + changed_when: true + +- name: 检查 SSH service + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --, systemctl, is-active, ssh] + register: container_ssh_active + changed_when: false + failed_when: container_ssh_active.rc not in [0, 3, 4] + +- name: 检查 SSH 自启 + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --, systemctl, is-enabled, ssh] + register: container_ssh_enabled + changed_when: false + failed_when: container_ssh_enabled.rc not in [0, 1, 3, 4] + +- name: 启用 SSH + ansible.builtin.command: + argv: [incus, exec, "local:{{ incus_container }}", --, systemctl, enable, --now, ssh] + when: container_ssh_active.rc != 0 or container_ssh_enabled.stdout != 'enabled' + changed_when: true diff --git a/infrastructure/incus/ansible/templates/gateway.yaml.j2 b/infrastructure/incus/ansible/templates/gateway.yaml.j2 new file mode 100644 index 0000000..e2446ca --- /dev/null +++ b/infrastructure/incus/ansible/templates/gateway.yaml.j2 @@ -0,0 +1,109 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: incus +--- +apiVersion: v1 +kind: Service +metadata: + name: incus + namespace: incus +spec: + ports: + - name: https + port: 8443 + targetPort: 8443 +--- +apiVersion: discovery.k8s.io/v1 +kind: EndpointSlice +metadata: + name: incus-laptop + namespace: incus + labels: + kubernetes.io/service-name: incus + endpointslice.kubernetes.io/managed-by: homelab-ansible +addressType: IPv4 +ports: + - name: https + protocol: TCP + port: 8443 +endpoints: + - addresses: [192.168.10.127] + conditions: + ready: true +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: incus-backend-ca + namespace: incus +data: + ca.crt: | +{{ incus_backend_certificate.content | b64decode | indent(4, true) }} +--- +apiVersion: gateway.networking.k8s.io/v1alpha3 +kind: BackendTLSPolicy +metadata: + name: incus + namespace: incus +spec: + targetRefs: + - group: '' + kind: Service + name: incus + validation: + hostname: laptop + caCertificateRefs: + - group: '' + kind: ConfigMap + name: incus-backend-ca +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: incus + namespace: incus +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: eg + namespace: envoy-gateway-system + sectionName: https + hostnames: [incus.ad.ddupan.top] + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - group: '' + kind: Service + name: incus + port: 8443 + weight: 1 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: incus-http + namespace: incus +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: eg + namespace: envoy-gateway-system + sectionName: http + hostnames: [incus.ad.ddupan.top] + rules: + - matches: + - path: + type: PathPrefix + value: / + filters: + - type: RequestRedirect + requestRedirect: + scheme: https + port: 443 + statusCode: 301 diff --git a/infrastructure/incus/scripts/reconcile_authelia.py b/infrastructure/incus/scripts/reconcile_authelia.py new file mode 100644 index 0000000..c399b3f --- /dev/null +++ b/infrastructure/incus/scripts/reconcile_authelia.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""只协调 Incus client/policy,保留线上其他客户端、秘密和 claims 配置。""" +import argparse +import copy +import json +import subprocess +import tempfile +from pathlib import Path + +import yaml + + +def merge(values, desired): + result = copy.deepcopy(values) + oidc = result['configMap']['identity_providers']['oidc'] + clients = oidc.setdefault('clients', []) + for client in desired['clients']: + matches = [i for i, old in enumerate(clients) if old['client_id'] == client['client_id']] + if len(matches) > 1: + raise ValueError('发现重复 client_id,拒绝自动覆盖') + if matches: + clients[matches[0]] = copy.deepcopy(client) + else: + clients.append(copy.deepcopy(client)) + oidc.setdefault('authorization_policies', {}).update(desired['authorization_policies']) + return result + + +def run(args): + # Helm 输出可能含秘密:只在内存处理,错误不回显正文。 + result = subprocess.run(args, capture_output=True, text=True) + if result.returncode: + raise RuntimeError(f'{args[0]} {args[1]} 失败(退出码 {result.returncode});未输出可能含秘密的响应') + return result.stdout + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--desired', required=True) + parser.add_argument('--check', action='store_true') + args = parser.parse_args() + release = json.loads(run(['helm', 'list', '-n', 'authelia', '-o', 'json'])) + current = next(x for x in release if x['name'] == 'authelia') + if current['chart'] != 'authelia-0.11.6' or current['status'] != 'deployed': + raise RuntimeError('仅验证 authelia-0.11.6 且 release 必须 deployed;请先审阅版本变化') + before = json.loads(run(['helm', 'get', 'values', 'authelia', '-n', 'authelia', '-o', 'json'])) + desired = yaml.safe_load(Path(args.desired).read_text()) + after = merge(before, desired) + if before == after: + print('changed=false: Incus client/policy 已收敛') + return + + # 目录 0700,文件 0600,退出即清理;既有秘密不写入仓库或日志。 + with tempfile.TemporaryDirectory(prefix='incus-authelia-') as directory: + root = Path(directory) + path = root / 'values.json' + path.touch(mode=0o600) + path.write_text(json.dumps(after)) + run(['helm', 'pull', 'authelia/authelia', '--version', '0.11.6', '--destination', directory]) + chart = str(root / 'authelia-0.11.6.tgz') + run(['helm', 'template', 'authelia', chart, '-n', 'authelia', '-f', str(path)]) + if args.check: + print('changed=true: 将只更新 Incus client/policy;固定 chart 渲染通过') + return + latest = next(x for x in json.loads(run(['helm', 'list', '-n', 'authelia', '-o', 'json'])) if x['name'] == 'authelia') + if latest['revision'] != current['revision'] or latest['status'] != 'deployed': + raise RuntimeError('Authelia release 在准备期间变化,请重试,避免覆盖并发修改') + run(['helm', 'upgrade', 'authelia', chart, '-n', 'authelia', '--reuse-values', + '-f', str(path), '--atomic', '--timeout', '5m', '--history-max', '10']) + print('changed=true: Incus client/policy 已部署,其他值保留') + + +if __name__ == '__main__': + main() diff --git a/infrastructure/incus/scripts/test_reconcile_authelia.py b/infrastructure/incus/scripts/test_reconcile_authelia.py new file mode 100644 index 0000000..87891f2 --- /dev/null +++ b/infrastructure/incus/scripts/test_reconcile_authelia.py @@ -0,0 +1,32 @@ +import unittest + +from reconcile_authelia import merge + + +class MergeTests(unittest.TestCase): + def test_preserves_unrelated_state_and_converges(self): + before = {'configMap': {'identity_providers': {'oidc': { + 'clients': [{'client_id': 'hydra', 'client_secret': 'test-only'}], + 'claims_policies': {'existing': {'id_token': ['email']}}, + 'authorization_policies': {'existing': {'default_policy': 'two_factor'}}, + }}}} + desired = {'clients': [{'client_id': 'incus', 'public': True}], + 'authorization_policies': {'incus_admin': {'default_policy': 'deny'}}} + after = merge(before, desired) + oidc = after['configMap']['identity_providers']['oidc'] + self.assertEqual(oidc['clients'][0], before['configMap']['identity_providers']['oidc']['clients'][0]) + self.assertIn('existing', oidc['claims_policies']) + self.assertIn('existing', oidc['authorization_policies']) + self.assertEqual(len(before['configMap']['identity_providers']['oidc']['clients']), 1) + self.assertEqual(merge(after, desired), after) + desired['clients'][0]['public'] = False + self.assertFalse(merge(after, desired)['configMap']['identity_providers']['oidc']['clients'][1]['public']) + + def test_rejects_duplicate_identity(self): + before = {'configMap': {'identity_providers': {'oidc': {'clients': [{'client_id': 'incus'}, {'client_id': 'incus'}]}}}} + with self.assertRaises(ValueError): + merge(before, {'clients': [{'client_id': 'incus'}], 'authorization_policies': {}}) + + +if __name__ == '__main__': + unittest.main() diff --git a/infrastructure/incus/terraform/.terraform.lock.hcl b/infrastructure/incus/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..94f5b2f --- /dev/null +++ b/infrastructure/incus/terraform/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/lxc/incus" { + version = "1.2.0" + constraints = "1.2.0" + hashes = [ + "h1:9G5MaYQY9mKIpO341aG6f0Bt46DFD/bssrtNB+r861U=", + "zh:3be797962ed009eedcd6badb2cce1f707345032d48814f050f2103f00eba0177", + "zh:53fd1bf8685ef140372ab3282267fba38219dd2351efa723888a80aa41aa9367", + "zh:59a9f9bd027380346b8ebc09da7c98c7ef5aed0783d33aa3d3c4f51ca2fafd0f", + "zh:65ca786dd942c068b5953e7bd92c6813b1aeeb8a381da7cc96c45a846f3a5965", + "zh:6dd3262124c41f8a416cbd1c289dbf1ab9bf9116fc3f3be4714971272811926a", + "zh:8cce2da3db95f1088e02bf7ee68d9cacd55bf7b12dd0dd1e61165d5e5432e38b", + "zh:a637c5299aeed3984a0b39b45f4bac026d701a6cc203dff05a82a4f43027f37c", + "zh:a9017e39f3e8d2dbbfbbc1c6d663d22465b783d1c0e9a6e3ab324b497d0b14ed", + "zh:c3954bf1f4796a529dd76f5617f63f570dee76e9b7c17b12416e3afb059314ba", + "zh:dd5a081a9778794d89fa54ccddf4287550e9522d6e69b5e777859857809c9d20", + "zh:fa95ea158d045386be416cf9146c74a5e4b3fe5fd85850e1b8f6d2977c45bc9b", + "zh:fbe8006406da07ba0c40282050cd34343a584d028890c8601592414044abab7e", + ] +} diff --git a/infrastructure/incus/terraform/main.tf b/infrastructure/incus/terraform/main.tf new file mode 100644 index 0000000..d21af58 --- /dev/null +++ b/infrastructure/incus/terraform/main.tf @@ -0,0 +1,102 @@ +terraform { + required_version = ">= 1.10.0" + required_providers { + incus = { + source = "lxc/incus" + version = "1.2.0" + } + } +} + +provider "incus" { + default_remote = "local" + remote { + name = "local" + address = "unix://" + } + remote { + name = "images" + address = "https://images.linuxcontainers.org" + protocol = "simplestreams" + public = true + } +} + +# 专用子 dataset,不接管整个宿主 data 池。 +resource "incus_storage_pool" "ayatori" { + name = "ayatori" + driver = "zfs" + config = { + source = "data/incus-ayatori" + } + lifecycle { + prevent_destroy = true + } +} + +resource "incus_instance" "ayatori" { + for_each = toset(["dev", "prod"]) + name = "ayatori-${each.key}" + description = "Ayatori ${each.key} 基础容器;应用由独立部署流程管理" + # Ubuntu 24.04 cloud amd64,20260924_07:42;固定指纹避免重建时静默更换镜像。 + image = "images:b39d3d56c30738ad74c507066cc5d410e1c0f1647bdcdff398be1ee51d63862a" + type = "container" + profiles = [] + running = true + config = { + "boot.autostart" = "true" + "security.privileged" = "false" + "security.nesting" = "false" + "limits.cpu" = "2" + "limits.memory" = "2GiB" + "limits.memory.swap" = "false" + "cloud-init.user-data" = "#cloud-config\n${yamlencode({ + hostname = "ayatori-${each.key}" + manage_etc_hosts = true + ssh_pwauth = false + disable_root = true + users = [{ + name = "panxiao81" + groups = ["sudo"] + shell = "/bin/bash" + sudo = ["ALL=(ALL) NOPASSWD:ALL"] + lock_passwd = true + ssh_authorized_keys = [trimspace(file("${path.module}/../ansible/files/panxiao81.pub"))] + }] + })}" + } + device { + name = "root" + type = "disk" + properties = { + path = "/" + pool = incus_storage_pool.ayatori.name + size = "20GiB" + } + } + device { + name = "eth0" + type = "nic" + properties = { + name = "eth0" + nictype = "bridged" + parent = "br0" + hwaddr = each.key == "dev" ? "02:16:3e:aa:00:01" : "02:16:3e:aa:00:02" + } + } + wait_for { + type = "ipv4" + nic = "eth0" + } + lifecycle { + prevent_destroy = true + } +} + +output "containers" { + value = { for env, instance in incus_instance.ayatori : env => { + name = instance.name + ipv4 = instance.ipv4_address + mac = instance.mac_address + } } +}