浏览代码

Initialize Ansible proxy deployment project

Add server base security role (SSH hardening, UFW, fail2ban, auto-updates).
Add Snell single-user proxy role with auto-generated PSK.
Add Trojan multi-user proxy role with YAML-based user config, Let's Encrypt TLS,
and nginx fallback. Include OpenSpec design docs and specs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
kotoyuuko 2 周之前
当前提交
5053ace211
共有 33 个文件被更改,包括 1305 次插入0 次删除
  1. 28 0
      .gitignore
  2. 127 0
      README.md
  3. 18 0
      ansible.cfg
  4. 0 0
      credentials/.gitkeep
  5. 13 0
      group_vars/all.yml
  6. 89 0
      inventory/hosts.yml.example
  7. 2 0
      openspec/changes/archive/2026-04-29-ansible-trojan-proxy/.openspec.yaml
  8. 56 0
      openspec/changes/archive/2026-04-29-ansible-trojan-proxy/design.md
  9. 32 0
      openspec/changes/archive/2026-04-29-ansible-trojan-proxy/proposal.md
  10. 60 0
      openspec/changes/archive/2026-04-29-ansible-trojan-proxy/specs/server-base/spec.md
  11. 50 0
      openspec/changes/archive/2026-04-29-ansible-trojan-proxy/specs/snell-service/spec.md
  12. 83 0
      openspec/changes/archive/2026-04-29-ansible-trojan-proxy/specs/trojan-multiuser/spec.md
  13. 57 0
      openspec/changes/archive/2026-04-29-ansible-trojan-proxy/tasks.md
  14. 20 0
      openspec/config.yaml
  15. 60 0
      openspec/specs/server-base/spec.md
  16. 50 0
      openspec/specs/snell-service/spec.md
  17. 83 0
      openspec/specs/trojan-multiuser/spec.md
  18. 10 0
      roles/base/handlers/main.yml
  19. 69 0
      roles/base/tasks/main.yml
  20. 6 0
      roles/base/templates/sshd-hardening.conf.j2
  21. 6 0
      roles/snell/defaults/main.yml
  22. 5 0
      roles/snell/handlers/main.yml
  23. 63 0
      roles/snell/tasks/main.yml
  24. 4 0
      roles/snell/templates/snell-server.conf.j2
  25. 14 0
      roles/snell/templates/snell.service.j2
  26. 9 0
      roles/trojan/defaults/main.yml
  27. 11 0
      roles/trojan/handlers/main.yml
  28. 201 0
      roles/trojan/tasks/main.yml
  29. 20 0
      roles/trojan/templates/config.json.j2
  30. 10 0
      roles/trojan/templates/nginx-fallback.conf.j2
  31. 18 0
      roles/trojan/templates/trojan-go.service.j2
  32. 26 0
      site.yml
  33. 5 0
      users.yml.example

+ 28 - 0
.gitignore

@@ -0,0 +1,28 @@
+# Ansible inventory (contains sensitive host info)
+inventory/hosts.yml
+
+# Claude local settings
+.claude/
+
+# Credentials and secrets
+credentials/*
+!credentials/.gitkeep
+users.yml
+
+# Generated output
+output/
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Editor
+*.swp
+*.swo
+*~
+.vscode/
+.idea/
+
+# OS
+.DS_Store

+ 127 - 0
README.md

@@ -0,0 +1,127 @@
+# Ansible Proxy Deploy
+
+支持多服务器一键部署 Trojan(多用户)和 Snell(单用户)代理服务的 Ansible 配置。
+
+## 特性
+
+- **基础安全配置**:SSH 加固、UFW 防火墙、fail2ban、自动安全更新
+- **Snell 服务**:单用户模式,支持自动 PSK 生成
+- **Trojan 服务**:多用户模式,用户通过 YAML 文件配置
+- **TLS 自动管理**:Let's Encrypt 证书自动申请和续期
+- **systemd 托管**:所有服务使用 systemd 单元管理
+
+## 目录结构
+
+```
+.
+├── ansible.cfg              # Ansible 配置
+├── site.yml                 # 主 playbook
+├── inventory/
+│   └── hosts.yml.example    # 主机清单模板
+├── group_vars/
+│   └── all.yml              # 全局变量
+├── roles/
+│   ├── base/                # 基础安全配置
+│   ├── snell/               # Snell 代理服务
+│   └── trojan/              # Trojan 代理服务(多用户)
+├── credentials/             # 自动生成的凭据(gitignored)
+├── users.yml                # Trojan 用户配置(gitignored)
+└── output/                  # 输出目录(gitignored)
+```
+
+## 快速开始
+
+### 1. 配置主机清单
+
+```bash
+cp inventory/hosts.yml.example inventory/hosts.yml
+```
+
+编辑 `inventory/hosts.yml`,填入你的服务器信息:
+
+```yaml
+all:
+  children:
+    servers:
+      hosts:
+        server1:
+          ansible_host: 1.2.3.4
+          ansible_user: ubuntu
+          ssh_port: 22
+    snell:
+      children:
+        servers:
+    trojan:
+      children:
+        servers:
+```
+
+### 2. 配置 Trojan 用户
+
+```bash
+cp users.yml.example users.yml
+```
+
+编辑 `users.yml`:
+
+```yaml
+trojan_users:
+  - name: alice
+    password: "your-secure-password-1"
+  - name: bob
+    password: "your-secure-password-2"
+```
+
+> 密码留空会自动生成 32 位随机密码并保存到 `credentials/` 目录。
+
+### 3. 配置域名和邮箱
+
+在 `group_vars/all.yml` 或命令行中设置:
+
+```yaml
+trojan_domain: "proxy.example.com"
+certbot_email: "admin@example.com"
+```
+
+### 4. 部署
+
+```bash
+# 部署全部服务
+ansible-playbook site.yml
+
+# 仅部署基础配置
+ansible-playbook site.yml --tags base
+
+# 仅部署 Snell
+ansible-playbook site.yml --limit snell
+
+# 仅部署 Trojan
+ansible-playbook site.yml --limit trojan
+```
+
+## 配置变量
+
+### Snell 变量(`roles/snell/defaults/main.yml`)
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `snell_version` | `v4.1.1` | Snell 版本 |
+| `snell_port` | `61080` | 监听端口 |
+| `snell_psk` | 自动生成 | 预共享密钥 |
+| `snell_ipv6` | `false` | 是否启用 IPv6 |
+
+### Trojan 变量(`roles/trojan/defaults/main.yml`)
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `trojan_version` | `0.10.6` | trojan-go 版本 |
+| `trojan_port` | `443` | 监听端口 |
+| `trojan_fallback_port` | `8080` | nginx fallback 端口 |
+| `trojan_domain` | `""` | TLS 证书域名(必填) |
+| `certbot_email` | `""` | Let's Encrypt 邮箱(必填) |
+
+## 注意事项
+
+- 首次运行前确保域名已解析到目标服务器 IP
+- SSH 加固后会禁用密码登录和 root 登录,请确保已配置 SSH 密钥
+- `inventory/hosts.yml`、`users.yml` 和 `credentials/` 已被 `.gitignore` 排除,请勿手动提交

+ 18 - 0
ansible.cfg

@@ -0,0 +1,18 @@
+[defaults]
+inventory = inventory/hosts.yml
+host_key_checking = False
+retry_files_enabled = False
+gathering = smart
+fact_caching = jsonfile
+fact_caching_connection = /tmp/ansible_facts_cache
+fact_caching_timeout = 3600
+
+[privilege_escalation]
+become = True
+become_method = sudo
+become_user = root
+become_ask_pass = False
+
+[ssh_connection]
+pipelining = True
+control_path = /tmp/ansible-ssh-%%h-%%p-%%r

+ 0 - 0
credentials/.gitkeep


+ 13 - 0
group_vars/all.yml

@@ -0,0 +1,13 @@
+base_packages:
+  - curl
+  - wget
+  - vim
+  - htop
+  - unzip
+  - ufw
+  - fail2ban
+  - unattended-upgrades
+
+ssh_port: "{{ ansible_port | default(22) }}"
+
+ansible_python_interpreter: auto_silent

+ 89 - 0
inventory/hosts.yml.example

@@ -0,0 +1,89 @@
+# Ansible Inventory
+# 复制此文件为 inventory/hosts.yml 并填入实际的服务器信息
+# 该文件已被 .gitignore 排除,不会进入版本控制
+
+all:
+  children:
+    # ============================================================
+    # servers: 所有受管服务器的父组
+    #   base role 会在此组的所有主机上运行
+    # ============================================================
+    servers:
+      hosts:
+        # -------------------- server1 --------------------
+        server1:
+          # ansible_host: 服务器的公网 IP 地址或域名
+          #   Ansible 将通过此地址 SSH 连接到服务器
+          ansible_host: 1.2.3.4
+
+          # ansible_user: SSH 登录用户名
+          #   可以是 root,也可以是具有 sudo 权限的普通用户(如 ubuntu, debian)
+          #   如果为非 root 用户,请确保该用户已配置 sudo 免密
+          ansible_user: root
+
+          # ansible_port: SSH 连接端口(Ansible 原生变量)
+          #   默认 22,如果服务器使用非标准 SSH 端口则修改此项
+          ansible_port: 22
+
+          # ssh_port: 服务器实际运行的 SSH 端口(本项目自定义变量)
+          #   base role 会根据此值配置 sshd 和 UFW 防火墙
+          #   若省略,则默认使用 ansible_port 的值
+          ssh_port: 22
+
+          # ansible_ssh_private_key_file: SSH 私钥路径
+          #   指定用于连接该服务器的 SSH 私钥文件
+          #   如果省略,将使用 ssh-agent 或默认的 ~/.ssh/id_rsa
+          # ansible_ssh_private_key_file: ~/.ssh/my_server_key
+
+        # -------------------- server2 --------------------
+        server2:
+          ansible_host: 5.6.7.8
+          ansible_user: ubuntu
+          ansible_port: 22
+          ssh_port: 22
+
+    # ============================================================
+    # snell: 需要部署 Snell 服务的服务器组
+    #   通过 children.servers 继承 servers 组中的所有主机
+    #   也可以单独列出特定主机,只在这些主机上部署 Snell
+    # ============================================================
+    snell:
+      children:
+        servers:
+      # 示例:只在特定子集上部署 Snell
+      # hosts:
+      #   server1:
+
+    # ============================================================
+    # trojan: 需要部署 Trojan 服务的服务器组
+    #   通过 children.servers 继承 servers 组中的所有主机
+    #   也可以单独列出特定主机
+    # ============================================================
+    trojan:
+      children:
+        servers:
+      # 示例:只在特定子集上部署 Trojan
+      # hosts:
+      #   server1:
+
+# ================================================================
+# 字段速查表
+# ================================================================
+#
+# ansible_host          必填  服务器 IP 或域名,Ansible 实际连接的地址
+# ansible_user          必填  SSH 登录用户名
+# ansible_port          可选  SSH 连接端口(默认 22)
+# ssh_port              可选  服务器 sshd 实际监听端口(默认取 ansible_port)
+# ansible_ssh_private_key_file  可选  SSH 私钥路径
+# ansible_ssh_extra_args        可选  额外的 SSH 参数
+#
+# ================================================================
+# 组说明
+# ================================================================
+#
+# servers   base role 的目标组,所有服务器必须在此组中
+# snell     snell role 的目标组,继承 servers 或单独定义
+# trojan    trojan role 的目标组,继承 servers 或单独定义
+#
+# 注意:一台服务器可以同时属于 snell 和 trojan 组,
+#       此时该服务器会同时部署两种服务。

+ 2 - 0
openspec/changes/archive/2026-04-29-ansible-trojan-proxy/.openspec.yaml

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

+ 56 - 0
openspec/changes/archive/2026-04-29-ansible-trojan-proxy/design.md

@@ -0,0 +1,56 @@
+## Context
+
+本项目需要一套 Ansible 自动化配置,用于在多台服务器上快速部署代理服务(Trojan + Snell)。参考项目 `ansible-proxy-chain` 使用了 relay/landing 链式架构和单用户配置,但本项目的需求不同:需要在多台服务器上独立部署服务,且 Trojan 需要支持多用户模式。
+
+Snell 是 Surge 开发团队设计的轻量级加密代理协议,仅支持单用户模式。Trojan 则需要在同一端口上支持多个用户同时连接,用户凭证通过外部 YAML 文件管理。
+
+## Goals / Non-Goals
+
+**Goals:**
+- 提供一键式多服务器 Ansible 部署流程
+- 每台服务器部署完成后具备基础安全能力(SSH 加固、防火墙、入侵检测、自动更新)
+- 支持在服务器上部署单用户 Snell 服务
+- 支持在服务器上部署多用户 Trojan 服务,用户通过 `users.yml` 配置
+- 自动管理 TLS 证书(Let's Encrypt + certbot)
+
+**Non-Goals:**
+- 不支持 relay/landing 链式代理架构
+- 不自动生成客户端配置文件(如 Surge/Clash 配置)
+- 不提供 Web 管理面板
+- 不支持 Shadowsocks 或其他代理协议
+
+## Decisions
+
+### 使用 trojan-go 作为 Trojan 实现
+- **Rationale**: trojan-go 是 trojan-gfw 的 Go 语言重写版,性能更好,维护更活跃,原生支持多用户配置(通过 `users` 数组)
+- **Alternatives considered**: trojan-gfw(C++ 版,多用户支持较弱,维护停滞)
+
+### 多用户配置使用独立 YAML 文件
+- **Rationale**: 将用户凭证从 `group_vars` 分离到 `users.yml`,便于单独管理和权限控制(可设置更严格的文件权限),同时保持 Ansible 变量结构的清晰
+- **格式**: `users.yml` 定义 `trojan_users` 列表,每个用户含 `name` 和 `password`
+- **Alternatives considered**: 直接放在 `group_vars/all.yml` 中(与其他配置混杂,不利于敏感信息隔离)
+
+### 使用 systemd 管理所有服务
+- **Rationale**: 现代 Linux 发行版的标准 init 系统,支持自动重启、日志收集、权限控制
+- **Implementation**: 每个服务创建独立 service unit,使用专用非 root 用户运行,通过 `AmbientCapabilities=CAP_NET_BIND_SERVICE` 绑定 443 端口
+
+### 使用 certbot + Let's Encrypt 管理 TLS 证书
+- **Rationale**: 免费、自动化、广泛支持。通过 deploy-hook 在证书续期后自动复制到服务目录并 reload 服务
+- **自动续期**: 依赖 certbot 内置的 systemd timer 或 cron
+
+### 单 playbook 多 host group 部署
+- **Rationale**: 使用单一 `site.yml` 定义所有 plays,通过 inventory host groups 区分不同角色的服务器
+- **Structure**: `all` 组运行基础配置,可选的 `snell` 组和 `trojan` 组分别部署对应服务
+
+## Risks / Trade-offs
+
+- **[Risk]** Snell 二进制文件需要从 GitHub Release 下载,国内服务器可能下载失败
+  - **Mitigation**: 支持通过变量配置自定义下载 URL / 代理;playbook 失败时提供清晰错误信息
+- **[Risk]** Let's Encrypt 证书申请需要域名已正确解析到服务器 IP
+  - **Mitigation**: 在 certbot 任务前增加域名解析检查;提供详细的部署前置条件文档
+- **[Risk]** 多用户 Trojan 配置变更后需要重载服务
+  - **Mitigation**: 使用 Ansible handler 监听配置模板变化,自动触发服务 reload
+- **[Risk]** `users.yml` 包含敏感凭证,可能意外提交到 Git
+  - **Mitigation**: 默认将 `users.yml` 加入 `.gitignore`;提供 `users.yml.example` 模板
+- **[Trade-off]** Trojan 使用 trojan-go 的 `users` 数组方式管理多用户,而非数据库后端
+  - 这限制了用户数量和管理灵活性,但保持了部署的简单性和无外部依赖的优势

+ 32 - 0
openspec/changes/archive/2026-04-29-ansible-trojan-proxy/proposal.md

@@ -0,0 +1,32 @@
+## Why
+
+当前项目需要一套支持多服务器的 Ansible 配置,能够一键部署 Trojan 和 Snell 代理服务。与参考的 `ansible-proxy-chain` 项目不同,本项目不需要 relay/landing 链式架构,而是需要直接在多台服务器上部署独立的代理服务,其中 Trojan 需要支持多用户配置。
+
+## What Changes
+
+- 新增 Ansible playbook 和 roles,支持多服务器基础安全配置(SSH 加固、UFW 防火墙、fail2ban、自动更新)
+- 新增 Snell 服务部署 role,支持单用户模式
+- 新增 Trojan 服务部署 role,支持多用户模式,用户通过 YAML 文件配置
+- 新增 inventory 和 group_vars 配置模板
+- 新增多用户 Trojan 的 YAML 配置文件格式和解析逻辑
+
+## Capabilities
+
+### New Capabilities
+
+- `server-base`: 服务器基础安全配置,包括包管理、SSH 加固、UFW 防火墙、fail2ban、自动安全更新
+- `snell-service`: 单用户 Snell 代理服务部署,包含安装、配置和 systemd 管理
+- `trojan-multiuser`: 多用户 Trojan 代理服务部署,支持通过 YAML 文件定义多个用户,包含 TLS 证书管理、配置模板和 systemd 管理
+
+### Modified Capabilities
+
+- 无现有 spec 需要修改(本项目为全新初始化)
+
+## Impact
+
+- 新增 Ansible roles: `base`, `snell`, `trojan`
+- 新增 inventory 配置和 group_vars 模板
+- 新增 `users.yml` 格式的多用户配置支持
+- 新增 systemd service 单元文件
+- 新增 Jinja2 配置模板
+- 依赖: Python 3, systemd, certbot (Trojan TLS)

+ 60 - 0
openspec/changes/archive/2026-04-29-ansible-trojan-proxy/specs/server-base/spec.md

@@ -0,0 +1,60 @@
+## ADDED Requirements
+
+### Requirement: Ansible inventory defines server groups
+The inventory SHALL define a `servers` host group containing all managed servers. Optional groups `snell` and `trojan` MAY be defined for targeted role deployment. The repository SHALL ship `inventory/hosts.yml.example` as a template; the actual `inventory/hosts.yml` SHALL be gitignored and created by the user.
+
+#### Scenario: Inventory is valid
+- **WHEN** the user copies `hosts.yml.example` to `hosts.yml` and fills in their values
+- **THEN** a `servers` group is available with at least one host
+- **THEN** optional `snell` and `trojan` groups can be defined
+
+#### Scenario: Non-root user with sudo
+- **WHEN** `ansible_user` is set to a non-root user (e.g., `ubuntu`)
+- **THEN** Ansible connects as that user and uses `become` for privilege escalation
+
+#### Scenario: Root user
+- **WHEN** `ansible_user` is set to `root`
+- **THEN** Ansible connects as root directly and `become` is a no-op
+
+### Requirement: Base packages are installed on all servers
+The base role SHALL install essential packages: `curl`, `wget`, `vim`, `htop`, `unzip`, `ufw`, `fail2ban`, `unattended-upgrades`.
+
+#### Scenario: Fresh server provisioning
+- **WHEN** the base role runs on a fresh Ubuntu/Debian server
+- **THEN** all listed packages are installed and available
+
+### Requirement: SSH is hardened
+The base role SHALL configure SSH to disable password authentication, disable root login, and only allow key-based authentication. The SSH port SHALL be configurable per host via `ssh_port`, defaulting to 22.
+
+#### Scenario: SSH hardening applied
+- **WHEN** the base role completes
+- **THEN** `/etc/ssh/sshd_config` has `PasswordAuthentication no`, `PermitRootLogin no`, and `PubkeyAuthentication yes`
+- **THEN** the sshd service is restarted
+
+#### Scenario: Custom SSH port per host
+- **WHEN** a host defines `ssh_port: 2222` in inventory
+- **THEN** sshd listens on port 2222
+- **THEN** UFW allows port 2222 instead of 22
+- **THEN** fail2ban monitors port 2222
+
+### Requirement: UFW firewall is configured with default deny
+The base role SHALL enable UFW with a default deny incoming policy. The base role SHALL allow the SSH port (configurable via `ssh_port`, default 22).
+
+#### Scenario: Firewall base rules
+- **WHEN** the base role completes
+- **THEN** UFW is active with default deny incoming
+- **THEN** the SSH port is allowed
+
+### Requirement: fail2ban protects SSH
+The base role SHALL configure fail2ban to monitor SSH login attempts and ban IPs after repeated failures.
+
+#### Scenario: fail2ban is active
+- **WHEN** the base role completes
+- **THEN** fail2ban is running with an SSH jail enabled
+
+### Requirement: Automatic security updates are enabled
+The base role SHALL enable `unattended-upgrades` for security patches.
+
+#### Scenario: Unattended upgrades configured
+- **WHEN** the base role completes
+- **THEN** unattended-upgrades is configured to auto-install security updates

+ 50 - 0
openspec/changes/archive/2026-04-29-ansible-trojan-proxy/specs/snell-service/spec.md

@@ -0,0 +1,50 @@
+## ADDED Requirements
+
+### Requirement: Snell binary is installed
+The snell role SHALL download and install the Snell server binary from official release artifacts to `/usr/local/bin/snell-server`. The version SHALL be configurable via `snell_version`.
+
+#### Scenario: Fresh installation
+- **WHEN** the snell role runs on a server without Snell installed
+- **THEN** the specified version of the Snell binary is downloaded and installed
+- **THEN** the binary is executable
+
+#### Scenario: Version upgrade
+- **WHEN** `snell_version` is changed and the playbook is re-run
+- **THEN** the binary is replaced with the new version
+- **THEN** the Snell service is restarted
+
+### Requirement: Snell configuration is templated
+The snell role SHALL generate a configuration file at `/etc/snell/snell-server.conf` from a Jinja2 template. The configuration SHALL include: `listen` address, `psk` (pre-shared key), and `ipv6` setting.
+
+#### Scenario: Configuration is generated from template
+- **WHEN** the snell role runs
+- **THEN** the config file is rendered with listen address, PSK, and ipv6 setting
+- **THEN** the config file permissions are restricted (readable only by root or snell service user)
+
+#### Scenario: Configuration change triggers restart
+- **WHEN** a snell variable is changed and the playbook is re-run
+- **THEN** the configuration file is updated
+- **THEN** the Snell service is restarted via handler
+
+### Requirement: Snell runs as a systemd service
+The snell role SHALL create a systemd unit file for Snell and ensure it is enabled and started.
+
+#### Scenario: Service is running
+- **WHEN** the snell role completes
+- **THEN** the Snell systemd service is enabled and running
+- **THEN** the service runs under a dedicated non-root user
+
+### Requirement: Snell port is allowed through UFW
+The snell role SHALL allow the Snell listen port through UFW.
+
+#### Scenario: Firewall allows Snell port
+- **WHEN** the snell role runs with `snell_port: 61080`
+- **THEN** UFW allows incoming TCP traffic on port 61080
+
+### Requirement: Snell PSK is auto-generated and persisted
+The snell role SHALL default the pre-shared key to an auto-generated value from `credentials/snell_psk`, with manual override supported via `snell_psk` variable.
+
+#### Scenario: PSK uses auto-generated default
+- **WHEN** the snell role runs without manual override
+- **THEN** the PSK comes from `credentials/snell_psk` lookup value
+- **THEN** if the credential file does not exist, a new 32-character random value is generated and saved

+ 83 - 0
openspec/changes/archive/2026-04-29-ansible-trojan-proxy/specs/trojan-multiuser/spec.md

@@ -0,0 +1,83 @@
+## ADDED Requirements
+
+### Requirement: Trojan users are configured via YAML file
+The trojan role SHALL read user credentials from a `users.yml` file located at the playbook root. The file SHALL define a `trojan_users` list where each entry contains `name` and `password` fields.
+
+#### Scenario: Users file exists with multiple entries
+- **WHEN** `users.yml` contains `trojan_users` with entries `[{name: alice, password: pass1}, {name: bob, password: pass2}]`
+- **THEN** the Trojan configuration includes both users
+
+#### Scenario: Users file is missing
+- **WHEN** `users.yml` does not exist and no `trojan_users` variable is defined elsewhere
+- **THEN** the playbook fails with a clear error message
+
+#### Scenario: Users file is gitignored
+- **WHEN** the repository is inspected
+- **THEN** `.gitignore` contains an entry for `users.yml`
+
+### Requirement: Trojan binary is installed
+The trojan role SHALL download and install the trojan-go binary from release artifacts to `/usr/local/bin/trojan-go`. The version SHALL be configurable via `trojan_version`.
+
+#### Scenario: Fresh installation
+- **WHEN** the trojan role runs on a server without trojan-go installed
+- **THEN** the specified version of the binary is downloaded and installed
+- **THEN** the binary is executable
+
+#### Scenario: Version upgrade
+- **WHEN** `trojan_version` is changed and the playbook is re-run
+- **THEN** the binary is replaced with the new version
+- **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.
+
+#### Scenario: Certificate provisioning
+- **WHEN** the trojan role runs with a configured `trojan_domain`
+- **THEN** certbot obtains a TLS certificate for that domain
+- **THEN** the certificate and key are copied to `/etc/trojan-go/tls/` owned by the trojan service user
+
+#### 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
+
+### Requirement: Trojan runs as a systemd service
+The trojan role SHALL create a systemd unit file for trojan-go and ensure it is enabled and started. The unit SHALL include both `AmbientCapabilities` and `CapabilityBoundingSet` for `CAP_NET_BIND_SERVICE`.
+
+#### Scenario: Service is running
+- **WHEN** the trojan role completes
+- **THEN** the Trojan systemd service is enabled and running
+- **THEN** the service runs under a dedicated non-root user
+- **THEN** the service user can read the TLS certificate and key files
+
+### Requirement: Trojan listens on port 443 with TLS
+The Trojan service SHALL listen on port 443 and terminate TLS using the Let's Encrypt certificate.
+
+#### Scenario: Trojan accepts connections on 443
+- **WHEN** a Trojan client connects to port 443 with valid credentials
+- **THEN** the connection is accepted and proxied
+
+#### Scenario: Non-Trojan traffic is handled by fallback
+- **WHEN** a non-Trojan HTTPS request arrives on port 443
+- **THEN** Trojan forwards it to a local fallback endpoint (if configured)
+
+### Requirement: Trojan configuration is templated
+The trojan role SHALL generate a JSON configuration file from a Jinja2 template. The config SHALL include: `run_type`, `local_addr`, `local_port`, `remote_addr`, `remote_port`, `password` array (from `trojan_users`), `ssl` section with cert/key paths, and `log_level`.
+
+#### Scenario: Multi-user configuration is generated
+- **WHEN** the trojan role runs with multiple users defined
+- **THEN** the config file contains a `password` array with all user passwords
+- **THEN** each user can authenticate with their respective password
+
+#### Scenario: Configuration change triggers restart
+- **WHEN** `users.yml` or trojan variables are changed and the playbook is re-run
+- **THEN** the configuration file is updated
+- **THEN** the Trojan service is restarted via handler
+
+### Requirement: Trojan port 443 is allowed through UFW
+The trojan role SHALL allow port 443 through UFW.
+
+#### Scenario: Firewall allows HTTPS port
+- **WHEN** the trojan role runs
+- **THEN** UFW allows incoming TCP traffic on port 443

+ 57 - 0
openspec/changes/archive/2026-04-29-ansible-trojan-proxy/tasks.md

@@ -0,0 +1,57 @@
+## 1. Project Scaffolding
+
+- [x] 1.1 Initialize Ansible project structure (`ansible.cfg`, `site.yml`, `roles/`)
+- [x] 1.2 Create `.gitignore` (inventory files, credentials, users.yml, output/)
+- [x] 1.3 Create `inventory/hosts.yml.example` with `servers`, `snell`, `trojan` groups
+- [x] 1.4 Create `group_vars/all.yml` with base variables and Python interpreter setting
+- [x] 1.5 Create `users.yml.example` with `trojan_users` list template
+- [x] 1.6 Create `credentials/` directory placeholder with `.gitkeep`
+- [x] 1.7 Create `README.md` with project overview and usage instructions
+
+## 2. Base Role (`roles/base/`)
+
+- [x] 2.1 Create `roles/base/tasks/main.yml` with package installation task
+- [x] 2.2 Implement SSH hardening tasks (disable password auth, disable root login, set custom port)
+- [x] 2.3 Implement UFW configuration tasks (default deny, allow SSH port)
+- [x] 2.4 Implement fail2ban configuration and SSH jail
+- [x] 2.5 Implement unattended-upgrades configuration
+- [x] 2.6 Add SSH handler for sshd restart
+- [x] 2.7 Add UFW handler for firewall reload
+
+## 3. Snell Role (`roles/snell/`)
+
+- [x] 3.1 Create `roles/snell/tasks/main.yml` with Snell binary download and install task
+- [x] 3.2 Create `roles/snell/templates/snell-server.conf.j2` with listen, psk, ipv6 settings
+- [x] 3.3 Implement configuration file deployment task with proper permissions
+- [x] 3.4 Create `roles/snell/templates/snell.service.j2` systemd unit file
+- [x] 3.5 Implement systemd service creation and start tasks
+- [x] 3.6 Add UFW rule for Snell port
+- [x] 3.7 Add auto-generated PSK logic via `credentials/snell_psk` lookup
+- [x] 3.8 Add Snell service restart handler
+
+## 4. Trojan Role (`roles/trojan/`)
+
+- [x] 4.1 Create `roles/trojan/tasks/main.yml` with trojan-go binary download and install task
+- [x] 4.2 Implement `users.yml` loading via `include_vars` at playbook level
+- [x] 4.3 Create `roles/trojan/templates/config.json.j2` with multi-user `password` array
+- [x] 4.4 Implement configuration file deployment task with proper permissions
+- [x] 4.5 Implement certbot TLS certificate provisioning tasks
+- [x] 4.6 Implement certificate deploy-hook for auto-renewal copy and service reload
+- [x] 4.7 Create `roles/trojan/templates/trojan-go.service.j2` systemd unit with `CAP_NET_BIND_SERVICE`
+- [x] 4.8 Implement systemd service creation and start tasks
+- [x] 4.9 Add UFW rule for port 443
+- [x] 4.10 Add Trojan service restart handler
+
+## 5. Playbook Integration
+
+- [x] 5.1 Create `site.yml` with plays: bootstrap, base (all), snell (`snell` group), trojan (`trojan` group)
+- [x] 5.2 Add `include_vars` for `users.yml` in the Trojan play
+- [x] 5.3 Verify handler triggers work correctly across roles
+- [x] 5.4 Add `ansible_python_interpreter: auto_silent` to `group_vars/all.yml`
+
+## 6. Testing and Validation
+
+- [x] 6.1 Run `ansible-playbook --check` to validate syntax and task structure
+- [x] 6.2 Verify all Jinja2 templates render without errors
+- [x] 6.3 Confirm `.gitignore` covers all sensitive files (credentials/, users.yml, inventory/hosts.yml)
+- [x] 6.4 Review that all spec requirements have corresponding task coverage

+ 20 - 0
openspec/config.yaml

@@ -0,0 +1,20 @@
+schema: spec-driven
+
+# Project context (optional)
+# This is shown to AI when creating artifacts.
+# Add your tech stack, conventions, style guides, domain knowledge, etc.
+# Example:
+#   context: |
+#     Tech stack: TypeScript, React, Node.js
+#     We use conventional commits
+#     Domain: e-commerce platform
+
+# Per-artifact rules (optional)
+# Add custom rules for specific artifacts.
+# Example:
+#   rules:
+#     proposal:
+#       - Keep proposals under 500 words
+#       - Always include a "Non-goals" section
+#     tasks:
+#       - Break tasks into chunks of max 2 hours

+ 60 - 0
openspec/specs/server-base/spec.md

@@ -0,0 +1,60 @@
+## ADDED Requirements
+
+### Requirement: Ansible inventory defines server groups
+The inventory SHALL define a `servers` host group containing all managed servers. Optional groups `snell` and `trojan` MAY be defined for targeted role deployment. The repository SHALL ship `inventory/hosts.yml.example` as a template; the actual `inventory/hosts.yml` SHALL be gitignored and created by the user.
+
+#### Scenario: Inventory is valid
+- **WHEN** the user copies `hosts.yml.example` to `hosts.yml` and fills in their values
+- **THEN** a `servers` group is available with at least one host
+- **THEN** optional `snell` and `trojan` groups can be defined
+
+#### Scenario: Non-root user with sudo
+- **WHEN** `ansible_user` is set to a non-root user (e.g., `ubuntu`)
+- **THEN** Ansible connects as that user and uses `become` for privilege escalation
+
+#### Scenario: Root user
+- **WHEN** `ansible_user` is set to `root`
+- **THEN** Ansible connects as root directly and `become` is a no-op
+
+### Requirement: Base packages are installed on all servers
+The base role SHALL install essential packages: `curl`, `wget`, `vim`, `htop`, `unzip`, `ufw`, `fail2ban`, `unattended-upgrades`.
+
+#### Scenario: Fresh server provisioning
+- **WHEN** the base role runs on a fresh Ubuntu/Debian server
+- **THEN** all listed packages are installed and available
+
+### Requirement: SSH is hardened
+The base role SHALL configure SSH to disable password authentication, disable root login, and only allow key-based authentication. The SSH port SHALL be configurable per host via `ssh_port`, defaulting to 22.
+
+#### Scenario: SSH hardening applied
+- **WHEN** the base role completes
+- **THEN** `/etc/ssh/sshd_config` has `PasswordAuthentication no`, `PermitRootLogin no`, and `PubkeyAuthentication yes`
+- **THEN** the sshd service is restarted
+
+#### Scenario: Custom SSH port per host
+- **WHEN** a host defines `ssh_port: 2222` in inventory
+- **THEN** sshd listens on port 2222
+- **THEN** UFW allows port 2222 instead of 22
+- **THEN** fail2ban monitors port 2222
+
+### Requirement: UFW firewall is configured with default deny
+The base role SHALL enable UFW with a default deny incoming policy. The base role SHALL allow the SSH port (configurable via `ssh_port`, default 22).
+
+#### Scenario: Firewall base rules
+- **WHEN** the base role completes
+- **THEN** UFW is active with default deny incoming
+- **THEN** the SSH port is allowed
+
+### Requirement: fail2ban protects SSH
+The base role SHALL configure fail2ban to monitor SSH login attempts and ban IPs after repeated failures.
+
+#### Scenario: fail2ban is active
+- **WHEN** the base role completes
+- **THEN** fail2ban is running with an SSH jail enabled
+
+### Requirement: Automatic security updates are enabled
+The base role SHALL enable `unattended-upgrades` for security patches.
+
+#### Scenario: Unattended upgrades configured
+- **WHEN** the base role completes
+- **THEN** unattended-upgrades is configured to auto-install security updates

+ 50 - 0
openspec/specs/snell-service/spec.md

@@ -0,0 +1,50 @@
+## ADDED Requirements
+
+### Requirement: Snell binary is installed
+The snell role SHALL download and install the Snell server binary from official release artifacts to `/usr/local/bin/snell-server`. The version SHALL be configurable via `snell_version`.
+
+#### Scenario: Fresh installation
+- **WHEN** the snell role runs on a server without Snell installed
+- **THEN** the specified version of the Snell binary is downloaded and installed
+- **THEN** the binary is executable
+
+#### Scenario: Version upgrade
+- **WHEN** `snell_version` is changed and the playbook is re-run
+- **THEN** the binary is replaced with the new version
+- **THEN** the Snell service is restarted
+
+### Requirement: Snell configuration is templated
+The snell role SHALL generate a configuration file at `/etc/snell/snell-server.conf` from a Jinja2 template. The configuration SHALL include: `listen` address, `psk` (pre-shared key), and `ipv6` setting.
+
+#### Scenario: Configuration is generated from template
+- **WHEN** the snell role runs
+- **THEN** the config file is rendered with listen address, PSK, and ipv6 setting
+- **THEN** the config file permissions are restricted (readable only by root or snell service user)
+
+#### Scenario: Configuration change triggers restart
+- **WHEN** a snell variable is changed and the playbook is re-run
+- **THEN** the configuration file is updated
+- **THEN** the Snell service is restarted via handler
+
+### Requirement: Snell runs as a systemd service
+The snell role SHALL create a systemd unit file for Snell and ensure it is enabled and started.
+
+#### Scenario: Service is running
+- **WHEN** the snell role completes
+- **THEN** the Snell systemd service is enabled and running
+- **THEN** the service runs under a dedicated non-root user
+
+### Requirement: Snell port is allowed through UFW
+The snell role SHALL allow the Snell listen port through UFW.
+
+#### Scenario: Firewall allows Snell port
+- **WHEN** the snell role runs with `snell_port: 61080`
+- **THEN** UFW allows incoming TCP traffic on port 61080
+
+### Requirement: Snell PSK is auto-generated and persisted
+The snell role SHALL default the pre-shared key to an auto-generated value from `credentials/snell_psk`, with manual override supported via `snell_psk` variable.
+
+#### Scenario: PSK uses auto-generated default
+- **WHEN** the snell role runs without manual override
+- **THEN** the PSK comes from `credentials/snell_psk` lookup value
+- **THEN** if the credential file does not exist, a new 32-character random value is generated and saved

+ 83 - 0
openspec/specs/trojan-multiuser/spec.md

@@ -0,0 +1,83 @@
+## ADDED Requirements
+
+### Requirement: Trojan users are configured via YAML file
+The trojan role SHALL read user credentials from a `users.yml` file located at the playbook root. The file SHALL define a `trojan_users` list where each entry contains `name` and `password` fields.
+
+#### Scenario: Users file exists with multiple entries
+- **WHEN** `users.yml` contains `trojan_users` with entries `[{name: alice, password: pass1}, {name: bob, password: pass2}]`
+- **THEN** the Trojan configuration includes both users
+
+#### Scenario: Users file is missing
+- **WHEN** `users.yml` does not exist and no `trojan_users` variable is defined elsewhere
+- **THEN** the playbook fails with a clear error message
+
+#### Scenario: Users file is gitignored
+- **WHEN** the repository is inspected
+- **THEN** `.gitignore` contains an entry for `users.yml`
+
+### Requirement: Trojan binary is installed
+The trojan role SHALL download and install the trojan-go binary from release artifacts to `/usr/local/bin/trojan-go`. The version SHALL be configurable via `trojan_version`.
+
+#### Scenario: Fresh installation
+- **WHEN** the trojan role runs on a server without trojan-go installed
+- **THEN** the specified version of the binary is downloaded and installed
+- **THEN** the binary is executable
+
+#### Scenario: Version upgrade
+- **WHEN** `trojan_version` is changed and the playbook is re-run
+- **THEN** the binary is replaced with the new version
+- **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.
+
+#### Scenario: Certificate provisioning
+- **WHEN** the trojan role runs with a configured `trojan_domain`
+- **THEN** certbot obtains a TLS certificate for that domain
+- **THEN** the certificate and key are copied to `/etc/trojan-go/tls/` owned by the trojan service user
+
+#### 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
+
+### Requirement: Trojan runs as a systemd service
+The trojan role SHALL create a systemd unit file for trojan-go and ensure it is enabled and started. The unit SHALL include both `AmbientCapabilities` and `CapabilityBoundingSet` for `CAP_NET_BIND_SERVICE`.
+
+#### Scenario: Service is running
+- **WHEN** the trojan role completes
+- **THEN** the Trojan systemd service is enabled and running
+- **THEN** the service runs under a dedicated non-root user
+- **THEN** the service user can read the TLS certificate and key files
+
+### Requirement: Trojan listens on port 443 with TLS
+The Trojan service SHALL listen on port 443 and terminate TLS using the Let's Encrypt certificate.
+
+#### Scenario: Trojan accepts connections on 443
+- **WHEN** a Trojan client connects to port 443 with valid credentials
+- **THEN** the connection is accepted and proxied
+
+#### Scenario: Non-Trojan traffic is handled by fallback
+- **WHEN** a non-Trojan HTTPS request arrives on port 443
+- **THEN** Trojan forwards it to a local fallback endpoint (if configured)
+
+### Requirement: Trojan configuration is templated
+The trojan role SHALL generate a JSON configuration file from a Jinja2 template. The config SHALL include: `run_type`, `local_addr`, `local_port`, `remote_addr`, `remote_port`, `password` array (from `trojan_users`), `ssl` section with cert/key paths, and `log_level`.
+
+#### Scenario: Multi-user configuration is generated
+- **WHEN** the trojan role runs with multiple users defined
+- **THEN** the config file contains a `password` array with all user passwords
+- **THEN** each user can authenticate with their respective password
+
+#### Scenario: Configuration change triggers restart
+- **WHEN** `users.yml` or trojan variables are changed and the playbook is re-run
+- **THEN** the configuration file is updated
+- **THEN** the Trojan service is restarted via handler
+
+### Requirement: Trojan port 443 is allowed through UFW
+The trojan role SHALL allow port 443 through UFW.
+
+#### Scenario: Firewall allows HTTPS port
+- **WHEN** the trojan role runs
+- **THEN** UFW allows incoming TCP traffic on port 443

+ 10 - 0
roles/base/handlers/main.yml

@@ -0,0 +1,10 @@
+---
+- name: reload ssh
+  ansible.builtin.systemd:
+    name: ssh
+    state: reloaded
+
+- name: restart fail2ban
+  ansible.builtin.systemd:
+    name: fail2ban
+    state: restarted

+ 69 - 0
roles/base/tasks/main.yml

@@ -0,0 +1,69 @@
+---
+- name: Update apt cache
+  ansible.builtin.apt:
+    update_cache: yes
+    cache_valid_time: 3600
+
+- name: Install base packages
+  ansible.builtin.apt:
+    name: "{{ base_packages }}"
+    state: present
+
+- name: Deploy SSH hardening drop-in
+  ansible.builtin.template:
+    src: sshd-hardening.conf.j2
+    dest: /etc/ssh/sshd_config.d/99-hardening.conf
+    owner: root
+    group: root
+    mode: "0644"
+  notify: reload ssh
+
+- name: Allow SSH through UFW
+  community.general.ufw:
+    rule: allow
+    port: "{{ ssh_port }}"
+    proto: tcp
+
+- name: Allow role-specific ports through UFW
+  community.general.ufw:
+    rule: allow
+    port: "{{ item }}"
+    proto: tcp
+  loop: "{{ allowed_ports | default([]) }}"
+
+- name: Enable UFW
+  community.general.ufw:
+    state: enabled
+    policy: deny
+    direction: incoming
+
+- name: Configure fail2ban SSH jail
+  ansible.builtin.copy:
+    dest: /etc/fail2ban/jail.local
+    content: |
+      [sshd]
+      enabled = true
+      port = {{ ssh_port }}
+      maxretry = 5
+      bantime = 3600
+      findtime = 600
+    owner: root
+    group: root
+    mode: "0644"
+  notify: restart fail2ban
+
+- name: Enable and start fail2ban
+  ansible.builtin.systemd:
+    name: fail2ban
+    enabled: yes
+    state: started
+
+- name: Configure unattended-upgrades
+  ansible.builtin.copy:
+    dest: /etc/apt/apt.conf.d/20auto-upgrades
+    content: |
+      APT::Periodic::Update-Package-Lists "1";
+      APT::Periodic::Unattended-Upgrade "1";
+    owner: root
+    group: root
+    mode: "0644"

+ 6 - 0
roles/base/templates/sshd-hardening.conf.j2

@@ -0,0 +1,6 @@
+Port {{ ssh_port }}
+PermitRootLogin no
+PubkeyAuthentication yes
+PasswordAuthentication no
+KbdInteractiveAuthentication no
+X11Forwarding no

+ 6 - 0
roles/snell/defaults/main.yml

@@ -0,0 +1,6 @@
+---
+snell_version: "v4.1.1"
+snell_download_url: "https://github.com/surge-networks/snell/releases/download/{{ snell_version }}/snell-server-{{ snell_version }}-linux-amd64.zip"
+snell_port: 61080
+snell_psk: "{{ lookup('password', 'credentials/snell_psk length=32 chars=ascii_letters,digits') }}"
+snell_ipv6: false

+ 5 - 0
roles/snell/handlers/main.yml

@@ -0,0 +1,5 @@
+---
+- name: restart snell
+  ansible.builtin.systemd:
+    name: snell
+    state: restarted

+ 63 - 0
roles/snell/tasks/main.yml

@@ -0,0 +1,63 @@
+---
+- name: Ensure snell config directory exists
+  ansible.builtin.file:
+    path: /etc/snell
+    state: directory
+    owner: root
+    group: root
+    mode: "0755"
+
+- name: Download snell-server binary
+  ansible.builtin.get_url:
+    url: "{{ snell_download_url }}"
+    dest: /tmp/snell-server.zip
+    mode: "0644"
+  register: snell_download
+
+- name: Unarchive snell-server binary
+  ansible.builtin.unarchive:
+    src: /tmp/snell-server.zip
+    dest: /usr/local/bin/
+    remote_src: yes
+    creates: /usr/local/bin/snell-server
+  when: snell_download is changed
+
+- name: Ensure snell-server is executable
+  ansible.builtin.file:
+    path: /usr/local/bin/snell-server
+    mode: "0755"
+    state: file
+
+- name: Deploy snell-server configuration
+  ansible.builtin.template:
+    src: snell-server.conf.j2
+    dest: /etc/snell/snell-server.conf
+    owner: root
+    group: root
+    mode: "0600"
+  notify: restart snell
+
+- name: Deploy snell systemd service
+  ansible.builtin.template:
+    src: snell.service.j2
+    dest: /etc/systemd/system/snell.service
+    owner: root
+    group: root
+    mode: "0644"
+  notify: restart snell
+
+- name: Reload systemd daemon
+  ansible.builtin.systemd:
+    daemon_reload: yes
+
+- name: Enable and start snell service
+  ansible.builtin.systemd:
+    name: snell
+    enabled: yes
+    state: started
+
+- name: Allow snell port through UFW
+  community.general.ufw:
+    rule: allow
+    port: "{{ snell_port }}"
+    proto: tcp

+ 4 - 0
roles/snell/templates/snell-server.conf.j2

@@ -0,0 +1,4 @@
+[snell-server]
+listen = 0.0.0.0:{{ snell_port }}
+psk = {{ snell_psk }}
+ipv6 = {{ snell_ipv6 | lower }}

+ 14 - 0
roles/snell/templates/snell.service.j2

@@ -0,0 +1,14 @@
+[Unit]
+Description=Snell Proxy Server
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/snell-server -c /etc/snell/snell-server.conf
+Restart=on-failure
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target

+ 9 - 0
roles/trojan/defaults/main.yml

@@ -0,0 +1,9 @@
+---
+trojan_version: "0.10.6"
+trojan_port: 443
+trojan_fallback_port: 8080
+trojan_bin_path: /usr/local/bin/trojan-go
+trojan_config_path: /etc/trojan-go/config.json
+trojan_user: trojan
+trojan_domain: ""
+certbot_email: ""

+ 11 - 0
roles/trojan/handlers/main.yml

@@ -0,0 +1,11 @@
+---
+- name: restart trojan
+  ansible.builtin.systemd:
+    name: trojan-go
+    daemon_reload: yes
+    state: restarted
+
+- name: restart nginx
+  ansible.builtin.systemd:
+    name: nginx
+    state: restarted

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

@@ -0,0 +1,201 @@
+---
+- name: Create trojan service user
+  ansible.builtin.user:
+    name: "{{ trojan_user }}"
+    system: yes
+    shell: /usr/sbin/nologin
+    create_home: no
+
+- name: Create trojan config directory
+  ansible.builtin.file:
+    path: "{{ trojan_config_path | dirname }}"
+    state: directory
+    owner: "{{ trojan_user }}"
+    group: "{{ trojan_user }}"
+    mode: "0750"
+
+- name: Download trojan-go binary
+  ansible.builtin.get_url:
+    url: "https://github.com/p4gefau1t/trojan-go/releases/download/v{{ trojan_version }}/trojan-go-linux-amd64.zip"
+    dest: /tmp/trojan-go.zip
+    mode: "0644"
+
+- name: Create extraction directory
+  ansible.builtin.file:
+    path: /tmp/trojan-go-extract/
+    state: directory
+    mode: "0755"
+
+- name: Extract trojan-go binary
+  ansible.builtin.unarchive:
+    src: /tmp/trojan-go.zip
+    dest: /tmp/trojan-go-extract/
+    remote_src: yes
+    creates: /tmp/trojan-go-extract/trojan-go
+
+- name: Install trojan-go binary
+  ansible.builtin.copy:
+    src: /tmp/trojan-go-extract/trojan-go
+    dest: "{{ trojan_bin_path }}"
+    remote_src: yes
+    owner: root
+    group: root
+    mode: "0755"
+  notify: restart trojan
+
+- name: Grant CAP_NET_BIND_SERVICE to trojan-go
+  community.general.capabilities:
+    path: "{{ trojan_bin_path }}"
+    capability: cap_net_bind_service=+ep
+    state: present
+
+- name: Clean up downloaded archive
+  ansible.builtin.file:
+    path: "{{ item }}"
+    state: absent
+  loop:
+    - /tmp/trojan-go.zip
+    - /tmp/trojan-go-extract
+
+- name: Install certbot
+  ansible.builtin.apt:
+    name:
+      - certbot
+    state: present
+
+- name: Obtain Let's Encrypt certificate
+  ansible.builtin.command:
+    cmd: >
+      certbot certonly --standalone
+      --non-interactive --agree-tos
+      --email {{ certbot_email }}
+      -d {{ trojan_domain }}
+    creates: "/etc/letsencrypt/live/{{ trojan_domain }}/fullchain.pem"
+
+- name: Grant trojan user read access to TLS certificates
+  ansible.builtin.file:
+    path: /etc/letsencrypt
+    state: directory
+    mode: "0755"
+
+- name: Ensure live directory is accessible
+  ansible.builtin.file:
+    path: "/etc/letsencrypt/live/{{ trojan_domain }}"
+    state: directory
+    mode: "0755"
+
+- name: Ensure archive directory is accessible
+  ansible.builtin.file:
+    path: "/etc/letsencrypt/archive/{{ trojan_domain }}"
+    state: directory
+    mode: "0755"
+
+- name: Deploy certbot renewal hook for trojan
+  ansible.builtin.copy:
+    dest: /etc/letsencrypt/renewal-hooks/post/trojan-go.sh
+    content: |
+      #!/bin/bash
+      mkdir -p /etc/trojan-go/tls
+      cp /etc/letsencrypt/live/{{ trojan_domain }}/fullchain.pem /etc/trojan-go/tls/fullchain.pem
+      cp /etc/letsencrypt/live/{{ trojan_domain }}/privkey.pem /etc/trojan-go/tls/privkey.pem
+      chown -R {{ trojan_user }}:{{ trojan_user }} /etc/trojan-go/tls
+      systemctl reload trojan-go
+    owner: root
+    group: root
+    mode: "0755"
+
+- name: Copy initial TLS certificates to trojan-owned directory
+  ansible.builtin.shell: |
+    mkdir -p /etc/trojan-go/tls
+    cp /etc/letsencrypt/live/{{ trojan_domain }}/fullchain.pem /etc/trojan-go/tls/fullchain.pem
+    cp /etc/letsencrypt/live/{{ trojan_domain }}/privkey.pem /etc/trojan-go/tls/privkey.pem
+    chown -R {{ trojan_user }}:{{ trojan_user }} /etc/trojan-go/tls
+  args:
+    creates: /etc/trojan-go/tls/privkey.pem
+  notify: restart trojan
+
+- name: Deploy trojan-go configuration
+  ansible.builtin.template:
+    src: config.json.j2
+    dest: "{{ trojan_config_path }}"
+    owner: "{{ trojan_user }}"
+    group: "{{ trojan_user }}"
+    mode: "0640"
+  notify: restart trojan
+
+- name: Deploy trojan-go systemd unit
+  ansible.builtin.template:
+    src: trojan-go.service.j2
+    dest: /etc/systemd/system/trojan-go.service
+    owner: root
+    group: root
+    mode: "0644"
+  notify: restart trojan
+
+- name: Install nginx for Trojan fallback
+  ansible.builtin.apt:
+    name:
+      - nginx
+    state: present
+
+- name: Deploy nginx fallback config
+  ansible.builtin.template:
+    src: nginx-fallback.conf.j2
+    dest: /etc/nginx/conf.d/trojan-fallback.conf
+    owner: root
+    group: root
+    mode: "0644"
+  notify: restart nginx
+
+- name: Create fallback web root
+  ansible.builtin.file:
+    path: /var/www/trojan-fallback
+    state: directory
+    owner: www-data
+    group: www-data
+    mode: "0755"
+
+- name: Deploy fallback index page
+  ansible.builtin.copy:
+    content: |
+      <!DOCTYPE html>
+      <html>
+      <head><title>Welcome</title></head>
+      <body><h1>Welcome</h1></body>
+      </html>
+    dest: /var/www/trojan-fallback/index.html
+    owner: www-data
+    group: www-data
+    mode: "0644"
+  notify: restart nginx
+
+- name: Remove default nginx site
+  ansible.builtin.file:
+    path: /etc/nginx/sites-enabled/default
+    state: absent
+  notify: restart nginx
+
+- name: Enable and start nginx
+  ansible.builtin.systemd:
+    name: nginx
+    enabled: yes
+    state: started
+
+- name: Enable and start trojan-go service
+  ansible.builtin.systemd:
+    name: trojan-go
+    daemon_reload: yes
+    enabled: yes
+    state: started
+
+- name: Enable certbot auto-renewal timer
+  ansible.builtin.systemd:
+    name: certbot.timer
+    enabled: yes
+    state: started
+
+- name: Allow Trojan port through UFW
+  community.general.ufw:
+    rule: allow
+    port: "{{ trojan_port }}"
+    proto: tcp

+ 20 - 0
roles/trojan/templates/config.json.j2

@@ -0,0 +1,20 @@
+{
+    "run_type": "server",
+    "local_addr": "0.0.0.0",
+    "local_port": {{ trojan_port }},
+    "remote_addr": "127.0.0.1",
+    "remote_port": {{ trojan_fallback_port }},
+    "password": [
+        {% for user in trojan_users %}"{{ user.password }}"{% if not loop.last %},
+        {% endif %}{% endfor %}
+    ],
+    "ssl": {
+        "cert": "/etc/trojan-go/tls/fullchain.pem",
+        "key": "/etc/trojan-go/tls/privkey.pem",
+        "sni": "{{ trojan_domain }}"
+    },
+    "router": {
+        "enabled": false
+    },
+    "log_level": 1
+}

+ 10 - 0
roles/trojan/templates/nginx-fallback.conf.j2

@@ -0,0 +1,10 @@
+server {
+    listen 127.0.0.1:{{ trojan_fallback_port }};
+    server_name _;
+    root /var/www/trojan-fallback;
+    index index.html;
+
+    location / {
+        try_files $uri $uri/ =404;
+    }
+}

+ 18 - 0
roles/trojan/templates/trojan-go.service.j2

@@ -0,0 +1,18 @@
+[Unit]
+Description=Trojan-Go Server
+After=network.target
+
+[Service]
+Type=simple
+User={{ trojan_user }}
+Group={{ trojan_user }}
+ExecStart={{ trojan_bin_path }} -config {{ trojan_config_path }}
+ExecReload=/bin/kill -HUP $MAINPID
+Restart=on-failure
+RestartSec=5
+LimitNOFILE=65536
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE
+
+[Install]
+WantedBy=multi-user.target

+ 26 - 0
site.yml

@@ -0,0 +1,26 @@
+---
+- name: Bootstrap Python on all hosts
+  hosts: all
+  gather_facts: false
+  become: true
+  tasks:
+    - name: Install Python 3
+      ansible.builtin.raw: apt-get update && apt-get install -y python3
+      changed_when: false
+
+- name: Base server setup (all hosts)
+  hosts: all
+  roles:
+    - base
+
+- name: Deploy Snell on snell servers
+  hosts: snell
+  roles:
+    - snell
+
+- name: Deploy Trojan on trojan servers
+  hosts: trojan
+  vars_files:
+    - users.yml
+  roles:
+    - trojan

+ 5 - 0
users.yml.example

@@ -0,0 +1,5 @@
+trojan_users:
+  - name: alice
+    password: "{{ lookup('password', 'credentials/trojan_alice length=32 chars=ascii_letters,digits') }}"
+  - name: bob
+    password: "{{ lookup('password', 'credentials/trojan_bob length=32 chars=ascii_letters,digits') }}"