diff --git a/deploy_acp_opencode_vhosts.yml b/deploy_acp_opencode_vhosts.yml index b0f4bbb..e39be14 100644 --- a/deploy_acp_opencode_vhosts.yml +++ b/deploy_acp_opencode_vhosts.yml @@ -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 diff --git a/deploy_acp_vhosts.yml b/deploy_acp_vhosts.yml new file mode 100644 index 0000000..8dcb77b --- /dev/null +++ b/deploy_acp_vhosts.yml @@ -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] diff --git a/deploy_codex_acp_vhosts.yml b/deploy_codex_acp_vhosts.yml index 9dd82ca..7e9d0fa 100644 --- a/deploy_codex_acp_vhosts.yml +++ b/deploy_codex_acp_vhosts.yml @@ -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 diff --git a/roles/cloudflare_dns/README.md b/roles/cloudflare_dns/README.md new file mode 100644 index 0000000..09640dc --- /dev/null +++ b/roles/cloudflare_dns/README.md @@ -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 +``` diff --git a/roles/cloudflare_dns/defaults/main.yml b/roles/cloudflare_dns/defaults/main.yml new file mode 100644 index 0000000..f24c39e --- /dev/null +++ b/roles/cloudflare_dns/defaults/main.yml @@ -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: [] diff --git a/roles/cloudflare_dns/tasks/main.yml b/roles/cloudflare_dns/tasks/main.yml new file mode 100644 index 0000000..07d775b --- /dev/null +++ b/roles/cloudflare_dns/tasks/main.yml @@ -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 }}" diff --git a/roles/vhosts/acp_codex/tasks/validate.yml b/roles/vhosts/acp_codex/tasks/validate.yml index ed2cfa8..b78ff48 100644 --- a/roles/vhosts/acp_codex/tasks/validate.yml +++ b/roles/vhosts/acp_codex/tasks/validate.yml @@ -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') }}" diff --git a/roles/vhosts/acp_opencode/tasks/validate.yml b/roles/vhosts/acp_opencode/tasks/validate.yml index 5f105fe..1478f51 100644 --- a/roles/vhosts/acp_opencode/tasks/validate.yml +++ b/roles/vhosts/acp_opencode/tasks/validate.yml @@ -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') }}" diff --git a/roles/vhosts/acp_vhosts/defaults/main.yml b/roles/vhosts/acp_vhosts/defaults/main.yml new file mode 100644 index 0000000..57f4ba5 --- /dev/null +++ b/roles/vhosts/acp_vhosts/defaults/main.yml @@ -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:* diff --git a/roles/vhosts/acp_vhosts/handlers/main.yml b/roles/vhosts/acp_vhosts/handlers/main.yml new file mode 100644 index 0000000..a233e60 --- /dev/null +++ b/roles/vhosts/acp_vhosts/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload caddy + ansible.builtin.service: + name: caddy + state: reloaded diff --git a/roles/vhosts/acp_vhosts/tasks/config.yml b/roles/vhosts/acp_vhosts/tasks/config.yml new file mode 100644 index 0000000..42561d6 --- /dev/null +++ b/roles/vhosts/acp_vhosts/tasks/config.yml @@ -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 diff --git a/roles/vhosts/acp_vhosts/tasks/main.yml b/roles/vhosts/acp_vhosts/tasks/main.yml new file mode 100644 index 0000000..6785c4f --- /dev/null +++ b/roles/vhosts/acp_vhosts/tasks/main.yml @@ -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] diff --git a/roles/vhosts/acp_vhosts/tasks/validate.yml b/roles/vhosts/acp_vhosts/tasks/validate.yml new file mode 100644 index 0000000..1ac2b25 --- /dev/null +++ b/roles/vhosts/acp_vhosts/tasks/validate.yml @@ -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 }}" diff --git a/roles/vhosts/acp_vhosts/templates/acp-site.caddy.j2 b/roles/vhosts/acp_vhosts/templates/acp-site.caddy.j2 new file mode 100644 index 0000000..2c67c89 --- /dev/null +++ b/roles/vhosts/acp_vhosts/templates/acp-site.caddy.j2 @@ -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 }} + } +} diff --git a/update_cloudflare_dns.yml b/update_cloudflare_dns.yml new file mode 100644 index 0000000..15254ef --- /dev/null +++ b/update_cloudflare_dns.yml @@ -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 diff --git a/vars/cloudflare_svc_plus_dns.yml b/vars/cloudflare_svc_plus_dns.yml new file mode 100644 index 0000000..a428d99 --- /dev/null +++ b/vars/cloudflare_svc_plus_dns.yml @@ -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