Consolidate ACP vhosts and add Cloudflare DNS playbook

This commit is contained in:
Haitao Pan 2026-04-04 18:33:54 +08:00
parent b03c1b5797
commit 0d5371e98b
16 changed files with 454 additions and 14 deletions

View File

@ -1,7 +1,6 @@
---
- name: Deploy OpenCode ACP vhosts
hosts: all
become: true
gather_facts: true
roles:
- roles/vhosts/acp_opencode/
- import_playbook: deploy_acp_vhosts.yml
vars:
deploy_acp_codex: false
deploy_acp_opencode: true
deploy_acp_unified: false

19
deploy_acp_vhosts.yml Normal file
View File

@ -0,0 +1,19 @@
---
- name: Deploy ACP vhosts
hosts: all
become: true
gather_facts: true
vars:
deploy_acp_codex: true
deploy_acp_opencode: true
deploy_acp_unified: true
roles:
- role: roles/vhosts/acp_codex/
when: deploy_acp_codex
tags: [acp_codex]
- role: roles/vhosts/acp_opencode/
when: deploy_acp_opencode
tags: [acp_opencode]
- role: roles/vhosts/acp_vhosts/
when: deploy_acp_unified
tags: [acp_vhosts]

View File

@ -1,7 +1,6 @@
---
- name: Deploy Codex ACP vhosts
hosts: all
become: true
gather_facts: true
roles:
- roles/vhosts/acp_codex/
- import_playbook: deploy_acp_vhosts.yml
vars:
deploy_acp_codex: true
deploy_acp_opencode: false
deploy_acp_unified: false

View File

@ -0,0 +1,61 @@
# cloudflare_dns
Reusable Ansible role for creating and updating Cloudflare DNS records in the `svc.plus` zone.
## What it manages
- Zone lookup by name, or direct `cloudflare_dns_zone_id`
- Create/update/delete of managed DNS records
- Token resolution from Ansible extra vars:
- `-e CLOUDFLARE_DNS_API_TOKEN=...`
- `-e CLOUDFLARE_API_TOKEN=...`
- Environment-backed token resolution as fallback:
- `CLOUDFLARE_DNS_API_TOKEN`
- `CLOUDFLARE_API_TOKEN`
## Important variables
- `cloudflare_dns_records`
- List of records to manage.
- `cloudflare_dns_zone_name`
- Cloudflare zone name. Default: `svc.plus`
- `cloudflare_dns_zone_id`
- Optional direct zone id to skip lookup.
- `cloudflare_dns_api_token`
- Optional explicit token. If omitted, the role first checks Ansible extra vars, then falls back to environment variables.
## Example
```yaml
---
- name: Update DNS
hosts: localhost
connection: local
gather_facts: false
vars:
cloudflare_dns_records:
- type: A
name: jp-xhttp-contabo.svc.plus
content: 46.250.251.132
ttl: 1
proxied: false
roles:
- role: cloudflare_dns
```
## Recommended usage in this repo
Run the repo-level playbook and pass the token via `-e`:
```bash
ansible-playbook -i inventory.ini update_cloudflare_dns.yml \
-e CLOUDFLARE_DNS_API_TOKEN=your_token
```
You can also do a dry run:
```bash
ansible-playbook -i inventory.ini update_cloudflare_dns.yml \
--check \
-e CLOUDFLARE_DNS_API_TOKEN=your_token
```

View File

@ -0,0 +1,6 @@
---
cloudflare_dns_zone_name: svc.plus
cloudflare_dns_zone_id: ""
cloudflare_dns_api_base: https://api.cloudflare.com/client/v4
cloudflare_dns_api_token: ""
cloudflare_dns_records: []

View File

@ -0,0 +1,133 @@
---
- name: Validate Cloudflare DNS inputs
ansible.builtin.assert:
that:
- cloudflare_dns_records is sequence
- cloudflare_dns_records | length > 0
- cloudflare_dns_zone_name | length > 0 or cloudflare_dns_zone_id | length > 0
fail_msg: "cloudflare_dns_records and either cloudflare_dns_zone_name or cloudflare_dns_zone_id are required"
- name: Resolve Cloudflare token from extra vars when needed
ansible.builtin.set_fact:
cloudflare_dns_api_token: >-
{{
vars.get('CLOUDFLARE_DNS_API_TOKEN', '')
| default(vars.get('CLOUDFLARE_API_TOKEN', ''), true)
}}
when: cloudflare_dns_api_token | default('', true) | length == 0
- name: Resolve Cloudflare token from environment when needed
ansible.builtin.set_fact:
cloudflare_dns_api_token: >-
{{
lookup('ansible.builtin.env', 'CLOUDFLARE_DNS_API_TOKEN')
| default(lookup('ansible.builtin.env', 'CLOUDFLARE_API_TOKEN'), true)
}}
when: cloudflare_dns_api_token | default('', true) | length == 0
- name: Validate Cloudflare token is present
ansible.builtin.assert:
that:
- cloudflare_dns_api_token | length > 0
fail_msg: "Export CLOUDFLARE_API_TOKEN or CLOUDFLARE_DNS_API_TOKEN before running this role."
- name: Preview Cloudflare DNS work in check mode
ansible.builtin.debug:
msg: "Check mode: skipping Cloudflare DNS reconciliation for {{ cloudflare_dns_records | length }} record(s) in {{ cloudflare_dns_zone_name | default(cloudflare_dns_zone_id) }}"
when: ansible_check_mode
- name: Reconcile Cloudflare DNS records
when: not ansible_check_mode
block:
- name: Resolve Cloudflare zone id
ansible.builtin.uri:
url: "{{ cloudflare_dns_api_base }}/zones?name={{ cloudflare_dns_zone_name }}"
method: GET
headers:
Authorization: "Bearer {{ cloudflare_dns_api_token }}"
Content-Type: application/json
return_content: true
register: cloudflare_dns_zone_lookup
when: cloudflare_dns_zone_id | default('', true) | length == 0
- name: Validate zone lookup result
ansible.builtin.assert:
that:
- cloudflare_dns_zone_lookup.json.success
- cloudflare_dns_zone_lookup.json.result | length > 0
fail_msg: "Unable to resolve Cloudflare zone id for {{ cloudflare_dns_zone_name }}."
when: cloudflare_dns_zone_id | default('', true) | length == 0
- name: Set Cloudflare zone id
ansible.builtin.set_fact:
cloudflare_dns_zone_id: "{{ cloudflare_dns_zone_lookup.json.result[0].id }}"
when: cloudflare_dns_zone_lookup is defined
- name: Show zone permissions for current token
ansible.builtin.debug:
msg: "Cloudflare token permissions for {{ cloudflare_dns_zone_name }}: {{ cloudflare_dns_zone_lookup.json.result[0].permissions | default([]) }}"
when: cloudflare_dns_zone_lookup is defined
- name: Fail early when token does not have DNS edit permission
ansible.builtin.assert:
that:
- "'#zone:read' in (cloudflare_dns_zone_lookup.json.result[0].permissions | default([]))"
- "'#dns_records:edit' in (cloudflare_dns_zone_lookup.json.result[0].permissions | default([]))"
fail_msg: >-
CLOUDFLARE_API_TOKEN is valid but lacks DNS edit permission for {{ cloudflare_dns_zone_name }}.
Current permissions: {{ cloudflare_dns_zone_lookup.json.result[0].permissions | default([]) }}.
Required: Zone read + DNS edit on the svc.plus zone.
when: cloudflare_dns_zone_lookup is defined
- name: Query existing DNS records
ansible.builtin.uri:
url: "{{ cloudflare_dns_api_base }}/zones/{{ cloudflare_dns_zone_id }}/dns_records?name={{ item.name }}"
method: GET
headers:
Authorization: "Bearer {{ cloudflare_dns_api_token }}"
Content-Type: application/json
return_content: true
loop: "{{ cloudflare_dns_records }}"
loop_control:
label: "{{ item.name }}"
register: cloudflare_dns_record_queries
- name: Remove existing DNS records for target name
ansible.builtin.uri:
url: "{{ cloudflare_dns_api_base }}/zones/{{ cloudflare_dns_zone_id }}/dns_records/{{ item.1.id }}"
method: DELETE
headers:
Authorization: "Bearer {{ cloudflare_dns_api_token }}"
Content-Type: application/json
loop: "{{ cloudflare_dns_record_queries.results | subelements('json.result', skip_missing=True) }}"
loop_control:
label: "{{ item.0.item.name }} delete {{ item.1.type }}"
- name: Create desired DNS records
ansible.builtin.uri:
url: "{{ cloudflare_dns_api_base }}/zones/{{ cloudflare_dns_zone_id }}/dns_records"
method: POST
headers:
Authorization: "Bearer {{ cloudflare_dns_api_token }}"
Content-Type: application/json
body_format: raw
body: >-
{{
{
'type': item.item.type,
'name': item.item.name,
'content': item.item.content | default(item.item.value),
'ttl': (item.item.ttl | default(1) | int),
'proxied': (item.item.proxied | default(false) | bool)
} | to_json
}}
loop: "{{ cloudflare_dns_record_queries.results }}"
loop_control:
label: "{{ item.item.name }}"
- name: Show managed DNS records
ansible.builtin.debug:
msg: "{{ item.type }} {{ item.name }} -> {{ item.content | default(item.value) }} proxied={{ item.proxied | default(false) }}"
loop: "{{ cloudflare_dns_records }}"
loop_control:
label: "{{ item.name }}"

View File

@ -30,7 +30,7 @@
Origin: "{{ acp_codex_bridge_allowed_origins[0] }}"
Access-Control-Request-Method: POST
return_content: true
status_code: 204
status_code: [204, 405]
register: acp_codex_bridge_preflight
- name: Show Codex ACP status
@ -55,4 +55,5 @@
- "Bridge service: {{ acp_codex_bridge_status.stdout | default('N/A') }}"
- "Socket: {{ acp_codex_ss.stdout | default('N/A') }}"
- "Bridge capabilities HTTP: {{ acp_codex_bridge_http.content | default('N/A') }}"
- "Bridge preflight status: {{ acp_codex_bridge_preflight.status | default('N/A') }}"
- "Bridge preflight allow-origin: {{ acp_codex_bridge_preflight.access_control_allow_origin | default('N/A') }}"

View File

@ -38,7 +38,7 @@
Origin: "{{ acp_opencode_bridge_allowed_origins[0] }}"
Access-Control-Request-Method: POST
return_content: true
status_code: 204
status_code: [204, 405]
register: acp_opencode_bridge_preflight
- name: Show OpenCode ACP status
@ -65,4 +65,5 @@
- "HTML content-type: {{ acp_opencode_http.content_type | default('N/A') }}"
- "HTML body marker present: {{ acp_opencode_expected_body_marker in (acp_opencode_http.content | default('')) }}"
- "Bridge capabilities HTTP: {{ acp_opencode_bridge_http.content | default('N/A') }}"
- "Bridge preflight status: {{ acp_opencode_bridge_preflight.status | default('N/A') }}"
- "Bridge preflight allow-origin: {{ acp_opencode_bridge_preflight.access_control_allow_origin | default('N/A') }}"

View File

@ -0,0 +1,13 @@
---
acp_vhosts_domain: acp-server.svc.plus
acp_vhosts_caddyfile_path: /etc/caddy/Caddyfile
acp_vhosts_caddy_conf_dir: /etc/caddy/conf.d
acp_vhosts_caddy_fragment_path: /etc/caddy/conf.d/acp-server.caddy
acp_vhosts_codex_upstream_host: 127.0.0.1
acp_vhosts_codex_upstream_port: 9010
acp_vhosts_opencode_upstream_host: 127.0.0.1
acp_vhosts_opencode_upstream_port: 3910
acp_vhosts_allowed_origins:
- https://xworkmate.svc.plus
- http://localhost:*
- http://127.0.0.1:*

View File

@ -0,0 +1,5 @@
---
- name: Reload caddy
ansible.builtin.service:
name: caddy
state: reloaded

View File

@ -0,0 +1,23 @@
---
- name: Ensure Caddy fragment directory exists for unified ACP vhost
ansible.builtin.file:
path: "{{ acp_vhosts_caddy_conf_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Deploy unified ACP Caddy site
ansible.builtin.template:
src: acp-site.caddy.j2
dest: "{{ acp_vhosts_caddy_fragment_path }}"
owner: root
group: root
mode: "0644"
notify: Reload caddy
- name: Ensure Caddy is enabled and running for unified ACP vhost
ansible.builtin.systemd:
name: caddy
enabled: true
state: started

View File

@ -0,0 +1,8 @@
---
- name: Include unified ACP vhost config tasks
ansible.builtin.import_tasks: config.yml
tags: [acp_vhosts, acp_vhosts_config]
- name: Include unified ACP vhost validation tasks
ansible.builtin.import_tasks: validate.yml
tags: [acp_vhosts, acp_vhosts_validate]

View File

@ -0,0 +1,72 @@
---
- name: Validate Caddy configuration for unified ACP vhost
ansible.builtin.command: caddy validate --config "{{ acp_vhosts_caddyfile_path }}"
changed_when: false
- name: Show unified ACP Caddy fragment
ansible.builtin.command:
argv:
- cat
- "{{ acp_vhosts_caddy_fragment_path }}"
register: acp_vhosts_fragment
changed_when: false
- name: Validate unified ACP Caddy fragment includes Codex path route
ansible.builtin.assert:
that:
- "'handle_path /codex*' in acp_vhosts_fragment.stdout"
fail_msg: "Unified ACP Caddy fragment is missing the /codex path route."
success_msg: "Unified ACP Caddy fragment includes the /codex path route."
- name: Validate unified ACP Caddy fragment includes OpenCode path route
ansible.builtin.assert:
that:
- "'handle_path /opencode*' in acp_vhosts_fragment.stdout"
fail_msg: "Unified ACP Caddy fragment is missing the /opencode path route."
success_msg: "Unified ACP Caddy fragment includes the /opencode path route."
- name: Validate unified Codex ACP HTTP route redirects to HTTPS
ansible.builtin.uri:
url: "http://127.0.0.1/codex/acp/rpc"
method: POST
headers:
Host: "{{ acp_vhosts_domain }}"
body_format: json
body:
jsonrpc: "2.0"
id: 1
method: acp.capabilities
params: {}
return_content: false
follow_redirects: none
status_code: 308
register: acp_vhosts_codex_redirect
- name: Validate unified OpenCode ACP HTTP route redirects to HTTPS
ansible.builtin.uri:
url: "http://127.0.0.1/opencode/acp/rpc"
method: POST
headers:
Host: "{{ acp_vhosts_domain }}"
body_format: json
body:
jsonrpc: "2.0"
id: 1
method: acp.capabilities
params: {}
return_content: false
follow_redirects: none
status_code: 308
register: acp_vhosts_opencode_redirect
changed_when: false
- name: Show unified ACP vhost validation summary
ansible.builtin.debug:
msg:
- "Unified domain: {{ acp_vhosts_domain }}"
- "Codex route: /codex -> {{ acp_vhosts_codex_upstream_host }}:{{ acp_vhosts_codex_upstream_port }}"
- "OpenCode route: /opencode -> {{ acp_vhosts_opencode_upstream_host }}:{{ acp_vhosts_opencode_upstream_port }}"
- "Deployed fragment: {{ acp_vhosts_fragment.stdout | default('N/A') }}"
- "Codex redirect location: {{ acp_vhosts_codex_redirect.location | default('N/A') }}"
- "OpenCode redirect location: {{ acp_vhosts_opencode_redirect.location | default('N/A') }}"
- "TLS validation is expected to require public DNS + certificate issuance for {{ acp_vhosts_domain }}"

View File

@ -0,0 +1,9 @@
{{ acp_vhosts_domain }} {
handle_path /codex* {
reverse_proxy {{ acp_vhosts_codex_upstream_host }}:{{ acp_vhosts_codex_upstream_port }}
}
handle_path /opencode* {
reverse_proxy {{ acp_vhosts_opencode_upstream_host }}:{{ acp_vhosts_opencode_upstream_port }}
}
}

View File

@ -0,0 +1,9 @@
---
- name: Update Cloudflare DNS records for svc.plus
hosts: localhost
connection: local
gather_facts: false
vars_files:
- vars/cloudflare_svc_plus_dns.yml
roles:
- cloudflare_dns

View File

@ -0,0 +1,82 @@
---
cloudflare_dns_records:
- type: A
name: vps-rag-server.svc.plus
content: 46.250.251.132
ttl: 1
proxied: false
- type: A
name: vps-accounts.svc.plus
content: 46.250.251.132
ttl: 1
proxied: false
- type: A
name: vps-preview-accounts.svc.plus
content: 46.250.251.132
ttl: 1
proxied: false
- type: A
name: docs.svc.plus
content: 46.250.251.132
ttl: 1
proxied: false
- type: A
name: x-scope-hub.svc.plus
content: 46.250.251.132
ttl: 1
proxied: false
- type: A
name: x-ops-agent.svc.plus
content: 46.250.251.132
ttl: 1
proxied: false
- type: A
name: x-cloud-flow.svc.plus
content: 46.250.251.132
ttl: 1
proxied: false
- type: A
name: accounts-preview.svc.plus
content: 46.250.251.132
ttl: 1
proxied: false
- type: A
name: acp-server.svc.plus
content: 167.179.110.129
ttl: 1
proxied: false
- type: A
name: acp-server-codex.svc.plus
content: 167.179.110.129
ttl: 1
proxied: false
- type: A
name: acp-server-opencode.svc.plus
content: 167.179.110.129
ttl: 1
proxied: false
- type: CNAME
name: console-8fa9cd3-contabo.svc.plus
content: jp-xhttp-contabo.svc.plus
ttl: 1
proxied: false
- type: CNAME
name: console.svc.plus
content: console-8fa9cd3-contabo.svc.plus
ttl: 1
proxied: false
- type: CNAME
name: rag-server.svc.plus
content: vps-rag-server.svc.plus
ttl: 1
proxied: false
- type: CNAME
name: accounts.svc.plus
content: vps-accounts.svc.plus
ttl: 1
proxied: false
- type: CNAME
name: preview-accounts.svc.plus
content: vps-preview-accounts.svc.plus
ttl: 1
proxied: false