Browse Source

Support per-host Trojan domain configuration

Move trojan_domain and certbot_email from group_vars to per-host inventory
variables so each server can have its own TLS domain and certificate.

- Add assert validation in trojan role to fail early with clear message
  if domain/email is missing for a host
- Update inventory/hosts.yml.example with per-host domain/email examples
- Update README.md with per-host configuration instructions
- Sync trojan-multiuser spec with per-host domain requirements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
kotoyuuko 2 weeks ago
parent
commit
d12879fcb4

+ 19 - 3
README.md

@@ -76,13 +76,29 @@ trojan_users:
 
 ### 3. 配置域名和邮箱
 
-在 `group_vars/all.yml` 或命令行中设置
+在 `inventory/hosts.yml` 中为每台部署 Trojan 的服务器单独配置域名和邮箱
 
 ```yaml
-trojan_domain: "proxy.example.com"
-certbot_email: "admin@example.com"
+all:
+  children:
+    servers:
+      hosts:
+        server1:
+          ansible_host: 1.2.3.4
+          ansible_user: ubuntu
+          ssh_port: 22
+          trojan_domain: "proxy1.example.com"
+          certbot_email: "admin@example.com"
+        server2:
+          ansible_host: 5.6.7.8
+          ansible_user: ubuntu
+          ssh_port: 22
+          trojan_domain: "proxy2.example.com"
+          certbot_email: "admin@example.com"
 ```
 
+> 每台 Trojan 服务器必须拥有独立的域名,且域名已解析到对应服务器的公网 IP。
+
 ### 4. 部署
 
 ```bash

+ 13 - 0
inventory/hosts.yml.example

@@ -35,12 +35,23 @@ all:
           #   如果省略,将使用 ssh-agent 或默认的 ~/.ssh/id_rsa
           # ansible_ssh_private_key_file: ~/.ssh/my_server_key
 
+          # trojan_domain: Trojan 服务使用的 TLS 域名
+          #   每台部署 Trojan 的服务器需要独立的域名
+          #   域名必须已解析到该服务器的公网 IP
+          # trojan_domain: "proxy1.example.com"
+
+          # certbot_email: Let's Encrypt 证书申请邮箱
+          #   用于接收证书到期提醒
+          # certbot_email: "admin@example.com"
+
         # -------------------- server2 --------------------
         server2:
           ansible_host: 5.6.7.8
           ansible_user: ubuntu
           ansible_port: 22
           ssh_port: 22
+          # trojan_domain: "proxy2.example.com"
+          # certbot_email: "admin@example.com"
 
     # ============================================================
     # snell: 需要部署 Snell 服务的服务器组
@@ -76,6 +87,8 @@ all:
 # ssh_port              可选  服务器 sshd 实际监听端口(默认取 ansible_port)
 # ansible_ssh_private_key_file  可选  SSH 私钥路径
 # ansible_ssh_extra_args        可选  额外的 SSH 参数
+# trojan_domain         可选  该服务器的 Trojan TLS 域名(部署 Trojan 时必须)
+# certbot_email         可选  Let's Encrypt 邮箱(部署 Trojan 时必须)
 #
 # ================================================================
 # 组说明

+ 2 - 0
openspec/changes/archive/2026-04-29-per-host-trojan-domain/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-29

+ 38 - 0
openspec/changes/archive/2026-04-29-per-host-trojan-domain/design.md

@@ -0,0 +1,38 @@
+## Context
+
+当前 `trojan_domain` 和 `certbot_email` 定义在 `group_vars/all.yml` 中,所有 `trojan` 组内的服务器共用同一域名申请 Let's Encrypt 证书。实际部署中,每台服务器通常有独立的公网 IP 和域名,需要分别为每台主机配置证书。
+
+## Goals / Non-Goals
+
+**Goals:**
+- 支持在 inventory 中为每台 Trojan 服务器独立配置域名和邮箱
+- 保持向后兼容:若主机未定义域名,可回退到全局默认值或报错
+
+**Non-Goals:**
+- 不支持一台服务器绑定多个域名
+- 不修改 certbot 本身的申请逻辑
+
+## Decisions
+
+### 使用 inventory host variables 替代 group_vars
+
+- **Rationale**: Ansible 的变量优先级中,host variables 高于 group_vars。将 `trojan_domain` 和 `certbot_email` 从 `group_vars/all.yml` 移除,改由用户在 `inventory/hosts.yml` 中按主机定义,trojan role 无需修改引用方式即可生效。
+- **Alternatives considered**: 使用 `host_vars/` 目录文件化配置 — 对于少量主机,直接在 `hosts.yml` 中内联定义更直观,无需额外目录。
+
+### 保留 group_vars 中的默认值作为 fallback
+
+- **Rationale**: 在 `group_vars/all.yml` 中保留 `trojan_domain: ""` 和 `certbot_email: ""` 作为空默认值,当某台主机未定义时,playbook 可在 role 中通过 `assert` 模块报错,避免使用错误域名。
+- **Alternatives considered**: 完全移除全局变量 — 会导致未配置域名的主机直接使用未定义变量,错误信息不清晰。
+
+## Risks / Trade-offs
+
+- **[Risk]** 现有用户已习惯在 `group_vars/all.yml` 中配置域名,迁移时可能遗漏
+  - **Mitigation**: 在 `inventory/hosts.yml.example` 中增加显眼的 per-host 域名示例;在 role 中增加 `assert` 前置检查,给出清晰错误提示
+- **[Risk]** `group_vars/all.yml` 中若保留非空默认值,会被所有主机继承,导致 host variable 无法生效
+  - **Mitigation**: 将 `group_vars/all.yml` 中的默认值设为空字符串,确保用户必须在 inventory 中显式配置
+
+## Migration Plan
+
+1. 从 `group_vars/all.yml` 中移除 `trojan_domain` 和 `certbot_email`(或设为空字符串)
+2. 在 `inventory/hosts.yml` 中为每台 trojan 服务器添加 `trojan_domain` 和 `certbot_email`
+3. 重新运行 playbook

+ 28 - 0
openspec/changes/archive/2026-04-29-per-host-trojan-domain/proposal.md

@@ -0,0 +1,28 @@
+## Why
+
+当前所有 Trojan 服务器共用 `group_vars/all.yml` 中统一定义的 `trojan_domain`,无法为每台服务器配置独立的域名和 TLS 证书。在多服务器部署场景中,每台服务器通常拥有独立的公网 IP 和域名,需要各自申请独立的 Let's Encrypt 证书。
+
+## What Changes
+
+- **BREAKING** 将 `trojan_domain` 从 `group_vars/all.yml` 的全局变量改为 per-host inventory 变量
+- **BREAKING** 将 `certbot_email` 同样改为 per-host inventory 变量(或保留全局默认值)
+- 更新 `inventory/hosts.yml.example`,展示每台主机独立配置域名的方式
+- 更新 trojan role,使用 `hostvars[inventory_hostname]` 或直接从 host facts 读取域名
+- 更新 `README.md` 中的配置说明
+
+## Capabilities
+
+### New Capabilities
+
+- 无新增能力
+
+### Modified Capabilities
+
+- `trojan-multiuser`: TLS 证书域名的配置方式从全局统一改为 per-host 独立配置。`trojan_domain` 和 `certbot_email` 变更为每台主机在 inventory 中单独定义。
+
+## Impact
+
+- `group_vars/all.yml` 中移除 `trojan_domain` 和 `certbot_email`
+- `inventory/hosts.yml.example` 增加 per-host 域名配置示例
+- `roles/trojan/` 中引用 `trojan_domain` 和 `certbot_email` 的方式不变(Ansible 变量优先级会自动处理)
+- 现有使用全局 `trojan_domain` 的用户需要迁移到 inventory per-host 配置

+ 37 - 0
openspec/changes/archive/2026-04-29-per-host-trojan-domain/specs/trojan-multiuser/spec.md

@@ -0,0 +1,37 @@
+## MODIFIED Requirements
+
+### Requirement: TLS certificate is provisioned via Let's Encrypt
+The trojan role SHALL use certbot to obtain a TLS certificate for the domain configured on each individual host. After provisioning or renewal, the certificate and key SHALL be copied to `/etc/trojan-go/tls/` so the service user can read them.
+
+#### Scenario: Certificate provisioning with per-host domain
+- **WHEN** the trojan role runs on a host with `trojan_domain: "proxy1.example.com"`
+- **THEN** certbot obtains a TLS certificate for `proxy1.example.com`
+- **THEN** the certificate and key are copied to `/etc/trojan-go/tls/` owned by the trojan service user
+
+#### Scenario: Certificate provisioning on a second host with different domain
+- **WHEN** the trojan role runs on a host with `trojan_domain: "proxy2.example.com"`
+- **THEN** certbot obtains a TLS certificate for `proxy2.example.com`
+- **THEN** the certificate is independent from other hosts
+
+#### Scenario: Certificate auto-renewal
+- **WHEN** the certificate is within 30 days of expiry
+- **THEN** certbot renews it automatically
+- **THEN** a deploy-hook copies the renewed certs to `/etc/trojan-go/tls/`
+- **THEN** the Trojan service is reloaded after renewal
+
+## ADDED Requirements
+
+### Requirement: Trojan domain is configured per-host
+Each host in the `trojan` group SHALL define its own `trojan_domain` and `certbot_email` variables in the inventory. The trojan role SHALL fail with a clear error if a host lacks these variables.
+
+#### Scenario: Host defines its own domain
+- **WHEN** a host in inventory has `trojan_domain: "proxy.example.com"` and `certbot_email: "admin@example.com"`
+- **THEN** the trojan role uses these values for that host
+
+#### Scenario: Host missing domain variable
+- **WHEN** a host in the `trojan` group does not define `trojan_domain`
+- **THEN** the playbook fails with an error indicating the missing variable
+
+#### Scenario: Inventory example shows per-host domain configuration
+- **WHEN** the user inspects `inventory/hosts.yml.example`
+- **THEN** it contains per-host `trojan_domain` and `certbot_email` examples

+ 22 - 0
openspec/changes/archive/2026-04-29-per-host-trojan-domain/tasks.md

@@ -0,0 +1,22 @@
+## 1. Variable Migration
+
+- [x] 1.1 Remove `trojan_domain` and `certbot_email` from `group_vars/all.yml` (or set to empty strings as fallback defaults)
+  - *Note: Variables already defined as empty strings in `roles/trojan/defaults/main.yml`, no change needed in `group_vars/all.yml`.*
+
+## 2. Inventory Update
+
+- [x] 2.1 Update `inventory/hosts.yml.example` to show per-host `trojan_domain` and `certbot_email` configuration
+
+## 3. Role Validation
+
+- [x] 3.1 Add `assert` task in `roles/trojan/tasks/main.yml` to fail early if `trojan_domain` or `certbot_email` is undefined/empty
+- [x] 3.2 Verify all existing `trojan_domain` and `certbot_email` references in trojan role work correctly with host variables
+  - *Note: All references use `{{ trojan_domain }}` and `{{ certbot_email }}`, Ansible variable resolution automatically handles host variables without code changes.*
+
+## 4. Documentation
+
+- [x] 4.1 Update `README.md` configuration section to reflect per-host domain setup
+
+## 5. Validation
+
+- [x] 5.1 Run `ansible-playbook --syntax-check` to validate playbook integrity

+ 24 - 4
openspec/specs/trojan-multiuser/spec.md

@@ -29,13 +29,18 @@ The trojan role SHALL download and install the trojan-go binary from release art
 - **THEN** the Trojan service is restarted
 
 ### Requirement: TLS certificate is provisioned via Let's Encrypt
-The trojan role SHALL use certbot to obtain a TLS certificate for the configured domain. After provisioning or renewal, the certificate and key SHALL be copied to `/etc/trojan-go/tls/` so the service user can read them.
+The trojan role SHALL use certbot to obtain a TLS certificate for the domain configured on each individual host. After provisioning or renewal, the certificate and key SHALL be copied to `/etc/trojan-go/tls/` so the service user can read them.
 
-#### Scenario: Certificate provisioning
-- **WHEN** the trojan role runs with a configured `trojan_domain`
-- **THEN** certbot obtains a TLS certificate for that domain
+#### Scenario: Certificate provisioning with per-host domain
+- **WHEN** the trojan role runs on a host with `trojan_domain: "proxy1.example.com"`
+- **THEN** certbot obtains a TLS certificate for `proxy1.example.com`
 - **THEN** the certificate and key are copied to `/etc/trojan-go/tls/` owned by the trojan service user
 
+#### Scenario: Certificate provisioning on a second host with different domain
+- **WHEN** the trojan role runs on a host with `trojan_domain: "proxy2.example.com"`
+- **THEN** certbot obtains a TLS certificate for `proxy2.example.com`
+- **THEN** the certificate is independent from other hosts
+
 #### Scenario: Certificate auto-renewal
 - **WHEN** the certificate is within 30 days of expiry
 - **THEN** certbot renews it automatically
@@ -75,6 +80,21 @@ The trojan role SHALL generate a JSON configuration file from a Jinja2 template.
 - **THEN** the configuration file is updated
 - **THEN** the Trojan service is restarted via handler
 
+### Requirement: Trojan domain is configured per-host
+Each host in the `trojan` group SHALL define its own `trojan_domain` and `certbot_email` variables in the inventory. The trojan role SHALL fail with a clear error if a host lacks these variables.
+
+#### Scenario: Host defines its own domain
+- **WHEN** a host in inventory has `trojan_domain: "proxy.example.com"` and `certbot_email: "admin@example.com"`
+- **THEN** the trojan role uses these values for that host
+
+#### Scenario: Host missing domain variable
+- **WHEN** a host in the `trojan` group does not define `trojan_domain`
+- **THEN** the playbook fails with an error indicating the missing variable
+
+#### Scenario: Inventory example shows per-host domain configuration
+- **WHEN** the user inspects `inventory/hosts.yml.example`
+- **THEN** it contains per-host `trojan_domain` and `certbot_email` examples
+
 ### Requirement: Trojan port 443 is allowed through UFW
 The trojan role SHALL allow port 443 through UFW.
 

+ 7 - 0
roles/trojan/tasks/main.yml

@@ -1,4 +1,11 @@
 ---
+- name: Assert trojan_domain is configured for this host
+  ansible.builtin.assert:
+    that:
+      - trojan_domain is defined and trojan_domain | length > 0
+      - certbot_email is defined and certbot_email | length > 0
+    fail_msg: "trojan_domain and certbot_email must be defined for each host in the trojan group. Set them in inventory/hosts.yml."
+
 - name: Create trojan service user
   ansible.builtin.user:
     name: "{{ trojan_user }}"