From 19e1f4ef1d98512f08d598c47c081555f2729a5a Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 10 Apr 2026 11:08:47 +0800 Subject: [PATCH] Add readonly SSH audit user role and playbooks --- README.md | 13 +- create_audit_user.yml | 24 +++ create_readonly_ssh_user.yml | 25 +++ ...=> deploy_svc_plus_core_services_stack.yml | 21 ++- roles/readonly_ssh_user/README.md | 128 ++++++++++++++ roles/readonly_ssh_user/defaults/main.yml | 111 ++++++++++++ roles/readonly_ssh_user/handlers/main.yml | 33 ++++ .../readonly-audit-checklist.md | 167 ++++++++++++++++++ roles/readonly_ssh_user/tasks/main.yml | 114 ++++++++++++ roles/vhosts/acp_codex/defaults/main.yml | 11 +- .../acp_codex/templates/acp-bridge.service.j2 | 4 +- roles/vhosts/acp_gemini/defaults/main.yml | 14 +- roles/vhosts/acp_gemini/tasks/config.yml | 4 +- roles/vhosts/xworkmate_bridge/README.md | 2 + 14 files changed, 649 insertions(+), 22 deletions(-) create mode 100644 create_audit_user.yml create mode 100644 create_readonly_ssh_user.yml rename deploy_traffic_billing_stack.yml => deploy_svc_plus_core_services_stack.yml (89%) create mode 100644 roles/readonly_ssh_user/README.md create mode 100644 roles/readonly_ssh_user/defaults/main.yml create mode 100644 roles/readonly_ssh_user/handlers/main.yml create mode 100644 roles/readonly_ssh_user/readonly-audit-checklist.md create mode 100644 roles/readonly_ssh_user/tasks/main.yml diff --git a/README.md b/README.md index cd898ac..0b2f937 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The traffic billing stack now has a single aggregate playbook: -`deploy_traffic_billing_stack.yml` +`deploy_svc_plus_core_services_stack.yml` It orchestrates these existing playbooks in dependency order: @@ -13,6 +13,7 @@ It orchestrates these existing playbooks in dependency order: 3. `deploy_accounts_svc_plus.yml` 4. `deploy_console_svc_plus.yml` 5. `deploy_agent_svc_plus.yml` +6. `deploy_xworkmate_bridge_vhosts.yml` ### Full stack deploy @@ -23,7 +24,7 @@ export DATABASE_URL=postgres://... export FRONTEND_IMAGE=ghcr.io/x-evor/dashboard:latest export STACK_TARGET_HOST=jp_xhttp_contabo_host export console_service_sync_dns=true -ansible-playbook -i inventory.ini deploy_traffic_billing_stack.yml +ansible-playbook -i inventory.ini deploy_svc_plus_core_services_stack.yml ``` `STACK_ENV_FILE=./.env` is optional. Use it when you want the aggregate playbook to read a local `.env` file; GitHub Actions or other CI runners can skip it and pass values with `-e` instead. @@ -39,7 +40,7 @@ export INTERNAL_SERVICE_TOKEN=... export DATABASE_URL=postgres://... export FRONTEND_IMAGE=ghcr.io/x-evor/dashboard:latest export console_service_sync_dns=true -ansible-playbook -i inventory.ini -l jp_xhttp_contabo_host deploy_traffic_billing_stack.yml +ansible-playbook -i inventory.ini -l jp_xhttp_contabo_host deploy_svc_plus_core_services_stack.yml ``` ### Deploy only selected services @@ -51,14 +52,15 @@ Use `STACK_SERVICES` with a comma-separated list: - `accounts` - `console` - `agent` +- `xworkmate-bridge` ```bash cd /Users/shenlan/workspaces/cloud-neutral-toolkit/playbooks export STACK_TARGET_HOST=jp-xhttp-contabo.svc.plus -export STACK_SERVICES=xray-exporter,billing-service,agent +export STACK_SERVICES=xray-exporter,billing-service,agent,xworkmate-bridge export INTERNAL_SERVICE_TOKEN=... export DATABASE_URL=postgres://... -ansible-playbook -i inventory.ini -l jp_xhttp_contabo_host deploy_traffic_billing_stack.yml +ansible-playbook -i inventory.ini -l jp_xhttp_contabo_host deploy_svc_plus_core_services_stack.yml ``` ### Notes @@ -68,6 +70,7 @@ ansible-playbook -i inventory.ini -l jp_xhttp_contabo_host deploy_traffic_billin - `console` now writes a Caddy fragment named like `---.caddy` instead of managing the Caddy service container itself. - `billing-service` requires `DATABASE_URL`. - `xray-exporter` and `agent` require `INTERNAL_SERVICE_TOKEN`. +- `xworkmate-bridge` accepts `XWORKMATE_BRIDGE_HOSTS`, and also follows `STACK_TARGET_HOST` when you want to deploy the whole stack to one host. ### Deploy console to a specific host and sync DNS diff --git a/create_audit_user.yml b/create_audit_user.yml new file mode 100644 index 0000000..26df12d --- /dev/null +++ b/create_audit_user.yml @@ -0,0 +1,24 @@ +--- +- name: Create a root-managed SSH audit user on selected hosts + hosts: all + become: true + gather_facts: true + + vars: + ansible_user: "{{ lookup('env', 'BOOTSTRAP_ROOT_USER') | default('root', true) }}" + ansible_password: "{{ lookup('env', 'BOOTSTRAP_ROOT_PASSWORD') | default(omit, true) }}" + ansible_become_password: "{{ lookup('env', 'BOOTSTRAP_BECOME_PASSWORD') | default(omit, true) }}" + + readonly_ssh_user_name: "{{ lookup('env', 'READONLY_SSH_USER_NAME') | default('readonly', true) }}" + readonly_ssh_user_profile: audit + readonly_ssh_user_lock_password: true + readonly_ssh_user_manage_sudoers: true + readonly_ssh_user_authorized_keys: >- + {{ + [lookup('env', 'READONLY_SSH_USER_PUBLIC_KEY')] + if lookup('env', 'READONLY_SSH_USER_PUBLIC_KEY') | default('', true) | length > 0 + else [] + }} + + roles: + - role: readonly_ssh_user diff --git a/create_readonly_ssh_user.yml b/create_readonly_ssh_user.yml new file mode 100644 index 0000000..504a40f --- /dev/null +++ b/create_readonly_ssh_user.yml @@ -0,0 +1,25 @@ +--- +- name: Create a readonly SSH user on selected hosts + hosts: all + become: true + gather_facts: true + + vars: + ansible_user: "{{ lookup('env', 'BOOTSTRAP_ROOT_USER') | default('root', true) }}" + ansible_password: "{{ lookup('env', 'BOOTSTRAP_ROOT_PASSWORD') | default(omit, true) }}" + ansible_become_password: "{{ lookup('env', 'BOOTSTRAP_BECOME_PASSWORD') | default(omit, true) }}" + + readonly_ssh_user_name: "{{ lookup('env', 'READONLY_SSH_USER_NAME') | default('readonly', true) }}" + readonly_ssh_user_profile: "{{ lookup('env', 'READONLY_SSH_USER_PROFILE') | default('readonly', true) }}" + readonly_ssh_user_password_hash: "{{ lookup('env', 'READONLY_SSH_USER_PASSWORD_HASH') | default('', true) }}" + readonly_ssh_user_lock_password: "{{ lookup('env', 'READONLY_SSH_LOCK_PASSWORD') | default('true', true) | bool }}" + readonly_ssh_user_manage_sudoers: "{{ lookup('env', 'READONLY_SSH_ENABLE_SUDO') | default('false', true) | bool }}" + readonly_ssh_user_authorized_keys: >- + {{ + [lookup('env', 'READONLY_SSH_USER_PUBLIC_KEY')] + if lookup('env', 'READONLY_SSH_USER_PUBLIC_KEY') | default('', true) | length > 0 + else [] + }} + + roles: + - role: readonly_ssh_user diff --git a/deploy_traffic_billing_stack.yml b/deploy_svc_plus_core_services_stack.yml similarity index 89% rename from deploy_traffic_billing_stack.yml rename to deploy_svc_plus_core_services_stack.yml index eae91a7..dd93b4c 100644 --- a/deploy_traffic_billing_stack.yml +++ b/deploy_svc_plus_core_services_stack.yml @@ -13,7 +13,7 @@ | default('', true), true) }} stack_services: >- {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent', true) }} + | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} when: "'xray-exporter' in (stack_services.split(',') | map('trim') | list)" - import_playbook: deploy_billing_service.yml @@ -35,7 +35,7 @@ | default('http://127.0.0.1:8080', true), true) }} stack_services: >- {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent', true) }} + | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} when: "'billing-service' in (stack_services.split(',') | map('trim') | list)" - import_playbook: deploy_accounts_svc_plus.yml @@ -57,7 +57,7 @@ | default('latest', true), true) }} stack_services: >- {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent', true) }} + | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} when: "'accounts' in (stack_services.split(',') | map('trim') | list)" - import_playbook: deploy_console_svc_plus.yml @@ -87,7 +87,7 @@ | default('', true), true) }} stack_services: >- {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent', true) }} + | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} when: "'console' in (stack_services.split(',') | map('trim') | list)" - import_playbook: deploy_agent_svc_plus.yml @@ -109,5 +109,16 @@ | default('http://127.0.0.1:8081', true), true) }} stack_services: >- {{ lookup('ansible.builtin.env', 'STACK_SERVICES') - | default('xray-exporter,billing-service,accounts,console,agent', true) }} + | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} when: "'agent' in (stack_services.split(',') | map('trim') | list)" + +- import_playbook: deploy_xworkmate_bridge_vhosts.yml + vars: + xworkmate_bridge_hosts: >- + {{ lookup('ansible.builtin.env', 'STACK_TARGET_HOST') + | default(lookup('ansible.builtin.env', 'XWORKMATE_BRIDGE_HOSTS') + | default('all', true), true) }} + stack_services: >- + {{ lookup('ansible.builtin.env', 'STACK_SERVICES') + | default('xray-exporter,billing-service,accounts,console,agent,xworkmate-bridge', true) }} + when: "'xworkmate-bridge' in (stack_services.split(',') | map('trim') | list)" diff --git a/roles/readonly_ssh_user/README.md b/roles/readonly_ssh_user/README.md new file mode 100644 index 0000000..f5fd90c --- /dev/null +++ b/roles/readonly_ssh_user/README.md @@ -0,0 +1,128 @@ +# readonly_ssh_user + +Create a remote SSH login user that can inspect the host as an unprivileged account but cannot modify protected system configuration. + +## TLDR + +export READONLY_SSH_USER_NAME=readonly Or auditor +export READONLY_SSH_LOCK_PASSWORD=true +export READONLY_SSH_ENABLE_SUDO=true +export READONLY_SSH_USER_PUBLIC_KEY='你的ssh公钥' + +ansible-playbook -i inventory.ini create_readonly_ssh_user.yml --limit jp-xhttp-contabo.svc.plus + +默认是 `readonly` profile。要创建更实用的“审计用户”,加上: + +```bash +export READONLY_SSH_USER_PROFILE=audit +``` + +## What it does + +- Creates a normal Linux user with no `sudo` or other privileged groups by default +- Locks the account password by default so password login is not usable +- Supports login with either a password hash or one or more SSH public keys +- Writes an `sshd_config.d` drop-in with per-user SSH restrictions +- Removes the user from common privileged groups such as `sudo`, `wheel`, and `docker` +- Optionally grants `sudo` for a tightly scoped read-only command whitelist +- Supports two profiles: + - `readonly`: minimal read-only sudo whitelist + - `audit`: broader inspection whitelist for logs, units, network, users, and common service status checks + +## Important limitation + +Linux 没法只靠“普通用户 + sudo 白名单”创建一个绝对不可变更的一般用途 SSH 审计用户。这个 role 提供的是“受限审计用户”,不是内核级只读沙箱。 + +This role creates an unprivileged user, not a kernel-enforced immutable sandbox. The user can still write to: + +- its own home directory +- any path that is already world-writable or group-writable to one of its groups + +It will not be able to change protected system configuration unless you explicitly grant extra privileges. + +If you enable limited sudo, the user still does not join the `sudo` group. Instead, the role writes a dedicated sudoers file with only approved read-only commands. + +For the default hardening mode used for `readonly`, the intended model is: + +- `passwd -l` style locked password +- SSH public key login only +- no password-based SSH login +- account lifecycle managed by `root` + +`audit` profile 适合: + +- 看系统配置 +- 看服务状态 +- 看日志 +- 看网络和用户状态 +- 做人工巡检 + +`audit` profile 不适合: + +- 真正执行 `ansible-playbook` 去修配置 +- 依赖 `become` 修改远端文件 +- 重启服务、安装包、改权限、写 `/etc` + +如果目标是“能用 Ansible 修复”,那已经不是审计用户,而是受限运维用户,需要单独设计 sudoers 白名单。 + +## Variables + +- `readonly_ssh_user_profile`: `readonly` or `audit`, default `readonly` +- `readonly_ssh_user_name`: username, default `readonly` +- `readonly_ssh_user_password_hash`: password hash for SSH password login +- `readonly_ssh_user_lock_password`: lock the local password, default `true` +- `readonly_ssh_user_authorized_keys`: list of SSH public keys for key-based login +- `readonly_ssh_user_groups`: supplementary groups to keep, default `[]` +- `readonly_ssh_user_manage_sshd`: whether to write an SSH Match block, default `true` +- `readonly_ssh_user_manage_sudoers`: whether to create a limited sudoers rule, default `false` +- `readonly_ssh_user_sudo_commands_readonly`: minimal whitelist +- `readonly_ssh_user_sudo_commands_audit`: broader audit whitelist +- `readonly_ssh_user_sudo_commands`: final whitelist, auto-derived from profile unless you override it +- `readonly_ssh_user_allow_tcp_forwarding`: default `false` +- `readonly_ssh_user_x11_forwarding`: default `false` +- `readonly_ssh_user_allow_agent_forwarding`: default `false` +- `readonly_ssh_user_force_command`: optional forced command + +## Example playbook + +```yaml +- hosts: jp_xhttp_contabo_host + become: true + roles: + - role: readonly_ssh_user + vars: + readonly_ssh_user_profile: audit + readonly_ssh_user_name: readonly + readonly_ssh_user_manage_sudoers: true + readonly_ssh_user_authorized_keys: + - "ssh-ed25519 AAAA..." +``` + +## About ansible-playbook -D -C + +这个角色创建的 `audit` 用户可以辅助“看”: + +- 读取配置 +- 看日志和 unit 状态 +- 运行手工审计命令 + +但它不应该被视为可以可靠执行 `ansible-playbook -D -C` 来“修正 role / playbook”的用户。原因是很多 Ansible 任务即使在 check mode 下,仍然需要更广的 `become` 能力、远端临时文件操作和模块执行权限。 + +如果你需要“Ansible 可修复但受限”的账号,建议单独建一个 `operator_lite` 角色,而不是继续扩大审计账号权限。 + +## Password hash example + +Generate a password hash locally: + +```bash +python3 -c 'import crypt, getpass; print(crypt.crypt(getpass.getpass(), crypt.mksalt(crypt.METHOD_SHA512)))' +``` + +## Root-managed SSH-only mode + +The default role behavior now matches this model: + +- the `readonly` user has a locked password +- SSH login is expected to happen by public key only +- `PasswordAuthentication no` is enforced for that user in the SSH Match block +- password resets and account management should be performed by `root` diff --git a/roles/readonly_ssh_user/defaults/main.yml b/roles/readonly_ssh_user/defaults/main.yml new file mode 100644 index 0000000..66c7e69 --- /dev/null +++ b/roles/readonly_ssh_user/defaults/main.yml @@ -0,0 +1,111 @@ +--- +readonly_ssh_user_profile: readonly +readonly_ssh_user_name: readonly +readonly_ssh_user_comment: "Read-only SSH user" +readonly_ssh_user_shell: /bin/bash +readonly_ssh_user_home: "/home/{{ readonly_ssh_user_name }}" +readonly_ssh_user_create_home: true +readonly_ssh_user_password_hash: "" +readonly_ssh_user_lock_password: true +readonly_ssh_user_authorized_keys: [] +readonly_ssh_user_append_groups: false +readonly_ssh_user_groups: [] +readonly_ssh_user_restricted_groups: + - sudo + - wheel + - adm + - docker + - lxd + - libvirt + - root +readonly_ssh_user_state: present +readonly_ssh_user_manage_sshd: true +readonly_ssh_user_sshd_dropin_dir: /etc/ssh/sshd_config.d +readonly_ssh_user_sshd_dropin_file: "99-{{ readonly_ssh_user_name }}-readonly.conf" +readonly_ssh_user_allow_tcp_forwarding: false +readonly_ssh_user_x11_forwarding: false +readonly_ssh_user_permit_tunnel: false +readonly_ssh_user_permit_tty: true +readonly_ssh_user_allow_agent_forwarding: false +readonly_ssh_user_password_authentication: false +readonly_ssh_user_pubkey_authentication: true +readonly_ssh_user_force_command: "" +readonly_ssh_service_name_override: "" +readonly_ssh_user_manage_sudoers: false +readonly_ssh_user_sudo_nopasswd: true +readonly_ssh_user_sudoers_file: "/etc/sudoers.d/{{ readonly_ssh_user_name }}-readonly" +readonly_ssh_user_sudo_commands_readonly: + - /usr/bin/cat * + - /usr/bin/head * + - /usr/bin/tail * + - /usr/bin/grep * + - /usr/bin/find * + - /usr/bin/ls * + - /usr/bin/stat * + - /usr/bin/du * + - /usr/bin/df * + - /usr/bin/ps * + - /usr/bin/ss * + - /usr/bin/free + - /usr/bin/uptime + - /usr/bin/id + - /usr/bin/uname -a + - /usr/bin/hostnamectl status + - /usr/bin/systemctl status * + - /usr/bin/systemctl show * + - /usr/bin/journalctl * +readonly_ssh_user_sudo_commands_audit: + - /usr/bin/cat * + - /usr/bin/head * + - /usr/bin/tail * + - /usr/bin/grep * + - /usr/bin/egrep * + - /usr/bin/fgrep * + - /usr/bin/find * + - /usr/bin/ls * + - /usr/bin/stat * + - /usr/bin/namei * + - /usr/bin/file * + - /usr/bin/du * + - /usr/bin/df * + - /usr/bin/ps * + - /usr/bin/ss * + - /usr/bin/free + - /usr/bin/uptime + - /usr/bin/id + - /usr/bin/uname -a + - /usr/bin/hostnamectl status + - /usr/bin/systemctl status * + - /usr/bin/systemctl show * + - /usr/bin/systemctl list-units * + - /usr/bin/systemctl list-unit-files * + - /usr/bin/systemctl cat * + - /usr/bin/journalctl * + - /usr/bin/loginctl * + - /usr/bin/env + - /usr/bin/printenv + - /usr/bin/whoami + - /usr/bin/w + - /usr/bin/who + - /usr/bin/last * + - /usr/bin/lastlog * + - /usr/bin/passwd -S * + - /usr/bin/getent * + - /usr/bin/crontab -l * + - /usr/sbin/ufw status + - /usr/sbin/ip addr * + - /usr/sbin/ip route * + - /usr/sbin/ip rule * + - /usr/sbin/iptables -S * + - /usr/sbin/ip6tables -S * + - /usr/sbin/nginx -T + - /usr/sbin/apachectl -S + - /usr/bin/docker ps * + - /usr/bin/docker images * + - /usr/bin/docker inspect * +readonly_ssh_user_sudo_commands: >- + {{ + readonly_ssh_user_sudo_commands_audit + if readonly_ssh_user_profile == 'audit' + else readonly_ssh_user_sudo_commands_readonly + }} diff --git a/roles/readonly_ssh_user/handlers/main.yml b/roles/readonly_ssh_user/handlers/main.yml new file mode 100644 index 0000000..1fefd2a --- /dev/null +++ b/roles/readonly_ssh_user/handlers/main.yml @@ -0,0 +1,33 @@ +--- +- name: Validate sshd configuration syntax + ansible.builtin.command: sshd -t + changed_when: false + when: not ansible_check_mode + listen: reload sshd + +- name: Collect service facts for ssh reload + ansible.builtin.service_facts: + changed_when: false + listen: reload sshd + +- name: Select SSH service name for readonly user role + ansible.builtin.set_fact: + readonly_ssh_service_name: >- + {{ + readonly_ssh_service_name_override + if readonly_ssh_service_name_override | length > 0 + else ('ssh' if 'ssh.service' in ansible_facts.services else 'sshd') + }} + listen: reload sshd + +- name: Reload SSH service + ansible.builtin.service: + name: "{{ readonly_ssh_service_name }}" + state: reloaded + listen: reload sshd + +- name: Validate sudoers syntax + ansible.builtin.command: "visudo -cf {{ readonly_ssh_user_sudoers_file }}" + changed_when: false + when: not ansible_check_mode + listen: validate sudoers diff --git a/roles/readonly_ssh_user/readonly-audit-checklist.md b/roles/readonly_ssh_user/readonly-audit-checklist.md new file mode 100644 index 0000000..4032545 --- /dev/null +++ b/roles/readonly_ssh_user/readonly-audit-checklist.md @@ -0,0 +1,167 @@ +# readonly audit checklist + +Use this checklist after creating a `readonly` or `audit` SSH user with the `readonly_ssh_user` role. + +The goal is to confirm the account can inspect system information and logs, but cannot make real changes such as editing protected files, restarting services, installing packages, or changing permissions. + +## 1. Login + +```bash +ssh readonly@jp-xhttp-contabo.svc.plus +``` + +## 2. Verify identity and sudo scope + +These commands should succeed: + +```bash +whoami +id +sudo -l +``` + +Expected: + +- current user is `readonly` +- user is not in `sudo`, `wheel`, `docker`, or other privileged groups unless explicitly intended +- `sudo -l` shows only the approved read-only whitelist + +## 3. Verify system and environment inspection + +These commands should succeed: + +```bash +uname -a +hostnamectl status +uptime +free -h +df -h +sudo env +``` + +Expected: + +- system identity and runtime information are visible +- no write action is performed + +## 4. Verify user, session, and scheduled task inspection + +These commands should succeed: + +```bash +sudo getent passwd | head +sudo last -n 20 +sudo lastlog | head +sudo crontab -l -u root +``` + +Expected: + +- account and login history can be reviewed +- root cron can be inspected if included in the sudo whitelist + +## 5. Verify network inspection + +These commands should succeed: + +```bash +sudo ip addr +sudo ip route +sudo ss -tulpn +sudo iptables -S +``` + +Expected: + +- interface, route, listening port, and firewall rule information is visible + +## 6. Verify service and log inspection + +These commands should succeed: + +```bash +sudo systemctl status ssh +sudo systemctl list-units --type=service --no-pager | head -50 +sudo journalctl -u ssh -n 50 --no-pager +``` + +Expected: + +- service status is visible +- logs can be inspected +- no service control actions are available + +## 7. Verify configuration file inspection + +These commands should succeed: + +```bash +sudo cat /etc/ssh/sshd_config +sudo cat /etc/passwd +sudo nginx -T +``` + +Expected: + +- protected configuration can be inspected through approved read-only commands +- config dump commands such as `nginx -T` work if the binary exists and is whitelisted + +## 8. Verify container inspection if Docker is present + +These commands should succeed when Docker is installed and included in the whitelist: + +```bash +sudo docker ps +sudo docker images +sudo docker inspect +``` + +Expected: + +- container and image metadata is visible +- no container lifecycle actions are permitted + +## 9. Verify modification attempts are blocked + +These commands should fail: + +```bash +sudo systemctl restart ssh +sudo systemctl reload nginx +sudo touch /etc/readonly-test +sudo cp /etc/hosts /etc/hosts.bak2 +sudo chmod 644 /etc/ssh/sshd_config +sudo useradd test123 +sudo apt install -y tree +``` + +Expected: + +- restart and reload commands fail +- writes under `/etc` fail +- permission changes fail +- package installation fails +- user creation fails + +## 10. Interpretation + +The account is behaving correctly if: + +- inspection commands succeed +- modification commands fail +- the account can read most operational information needed for audits +- the account still cannot apply real remediation + +If `ansible-playbook -D -C` is the target use case, this account is still not the right choice for remediation. It is an audit account, not a limited operator account. + +## 11. Optional follow-up + +If the account is too weak for your audit process: + +- add only specific additional read-only commands to the sudo whitelist +- avoid broad additions such as editor binaries, package managers, service restart commands, shell escapes, or file write utilities + +If the account is too strong: + +- remove commands from the `audit` whitelist +- create a stricter profile derived from `readonly` diff --git a/roles/readonly_ssh_user/tasks/main.yml b/roles/readonly_ssh_user/tasks/main.yml new file mode 100644 index 0000000..214659c --- /dev/null +++ b/roles/readonly_ssh_user/tasks/main.yml @@ -0,0 +1,114 @@ +--- +- name: Assert readonly SSH user profile is supported + ansible.builtin.assert: + that: + - readonly_ssh_user_profile in ['readonly', 'audit'] + fail_msg: "readonly_ssh_user_profile must be one of: readonly, audit" + +- name: Assert readonly SSH user has at least one login method + ansible.builtin.assert: + that: + - readonly_ssh_user_authorized_keys | length > 0 or (readonly_ssh_user_password_hash | length > 0 and not readonly_ssh_user_lock_password) + fail_msg: >- + Set readonly_ssh_user_authorized_keys for SSH key login, or provide an + unlocked readonly_ssh_user_password_hash if you intentionally want password login. + +- name: Set role comment based on selected profile when default is unchanged + ansible.builtin.set_fact: + readonly_ssh_user_effective_comment: >- + {{ + 'Audit SSH user' + if readonly_ssh_user_profile == 'audit' + else 'Read-only SSH user' + }} + +- name: Compute effective sudo command whitelist + ansible.builtin.set_fact: + readonly_ssh_user_effective_sudo_commands: "{{ readonly_ssh_user_sudo_commands }}" + +- name: Ensure readonly SSH user exists + ansible.builtin.user: + name: "{{ readonly_ssh_user_name }}" + comment: >- + {{ + readonly_ssh_user_comment + if readonly_ssh_user_comment != 'Read-only SSH user' + else readonly_ssh_user_effective_comment + }} + shell: "{{ readonly_ssh_user_shell }}" + home: "{{ readonly_ssh_user_home }}" + create_home: "{{ readonly_ssh_user_create_home }}" + password: "{{ readonly_ssh_user_password_hash if readonly_ssh_user_password_hash | length > 0 else omit }}" + password_lock: "{{ readonly_ssh_user_lock_password }}" + groups: "{{ readonly_ssh_user_groups }}" + append: "{{ readonly_ssh_user_append_groups }}" + state: "{{ readonly_ssh_user_state }}" + +- name: Ensure SSH directory exists for readonly user + ansible.builtin.file: + path: "{{ readonly_ssh_user_home }}/.ssh" + state: directory + owner: "{{ readonly_ssh_user_name }}" + group: "{{ readonly_ssh_user_name }}" + mode: "0700" + when: readonly_ssh_user_authorized_keys | length > 0 + +- name: Install authorized keys for readonly user + ansible.posix.authorized_key: + user: "{{ readonly_ssh_user_name }}" + key: "{{ item }}" + state: present + manage_dir: false + loop: "{{ readonly_ssh_user_authorized_keys }}" + when: readonly_ssh_user_authorized_keys | length > 0 + +- name: Remove readonly user from privileged groups + ansible.builtin.command: "gpasswd -d {{ readonly_ssh_user_name }} {{ item }}" + register: readonly_user_group_removal + failed_when: readonly_user_group_removal.rc not in [0, 3] + changed_when: readonly_user_group_removal.rc == 0 + loop: "{{ readonly_ssh_user_restricted_groups }}" + +- name: Ensure sshd drop-in directory exists + ansible.builtin.file: + path: "{{ readonly_ssh_user_sshd_dropin_dir }}" + state: directory + owner: root + group: root + mode: "0755" + when: readonly_ssh_user_manage_sshd + +- name: Configure readonly user SSH restrictions + ansible.builtin.copy: + dest: "{{ readonly_ssh_user_sshd_dropin_dir }}/{{ readonly_ssh_user_sshd_dropin_file }}" + owner: root + group: root + mode: "0644" + content: | + Match User {{ readonly_ssh_user_name }} + PasswordAuthentication {{ 'yes' if readonly_ssh_user_password_authentication else 'no' }} + PubkeyAuthentication {{ 'yes' if readonly_ssh_user_pubkey_authentication else 'no' }} + KbdInteractiveAuthentication no + ChallengeResponseAuthentication no + PermitEmptyPasswords no + AllowTcpForwarding {{ 'yes' if readonly_ssh_user_allow_tcp_forwarding else 'no' }} + X11Forwarding {{ 'yes' if readonly_ssh_user_x11_forwarding else 'no' }} + PermitTunnel {{ 'yes' if readonly_ssh_user_permit_tunnel else 'no' }} + PermitTTY {{ 'yes' if readonly_ssh_user_permit_tty else 'no' }} + AllowAgentForwarding {{ 'yes' if readonly_ssh_user_allow_agent_forwarding else 'no' }} + {% if readonly_ssh_user_force_command | length > 0 %} + ForceCommand {{ readonly_ssh_user_force_command }} + {% endif %} + notify: reload sshd + when: readonly_ssh_user_manage_sshd + +- name: Configure readonly sudo command whitelist + ansible.builtin.copy: + dest: "{{ readonly_ssh_user_sudoers_file }}" + owner: root + group: root + mode: "0440" + content: | + {{ readonly_ssh_user_name }} ALL=(root) {{ 'NOPASSWD:' if readonly_ssh_user_sudo_nopasswd else '' }} {{ readonly_ssh_user_effective_sudo_commands | join(', ') }} + notify: validate sudoers + when: readonly_ssh_user_manage_sudoers diff --git a/roles/vhosts/acp_codex/defaults/main.yml b/roles/vhosts/acp_codex/defaults/main.yml index e2c77a9..c4313a2 100644 --- a/roles/vhosts/acp_codex/defaults/main.yml +++ b/roles/vhosts/acp_codex/defaults/main.yml @@ -1,8 +1,11 @@ --- acp_codex_service_name: codex-app-server -acp_codex_service_user: root -acp_codex_service_group: root -acp_codex_workdir: /root +acp_codex_runtime_user: ubuntu +acp_codex_runtime_group: "{{ acp_codex_runtime_user }}" +acp_codex_runtime_home: "/home/{{ acp_codex_runtime_user }}" +acp_codex_service_user: "{{ acp_codex_runtime_user }}" +acp_codex_service_group: "{{ acp_codex_runtime_group }}" +acp_codex_workdir: "{{ acp_codex_runtime_home }}" acp_codex_listen_host: 127.0.0.1 acp_codex_listen_port: 9001 acp_codex_bridge_service_name: acp-bridge-codex @@ -22,7 +25,7 @@ acp_codex_bridge_allowed_origins: - http://localhost:* - http://127.0.0.1:* acp_codex_environment: - CODEX_HOME: "{{ acp_codex_workdir }}/.codex" + CODEX_HOME: "{{ acp_codex_runtime_home }}/.codex" OPENAI_API_KEY: "{{ lookup('ansible.builtin.env', 'OPENAI_API_KEY') | default('', true) }}" OPENAI_BASE_URL: "{{ lookup('ansible.builtin.env', 'OPENAI_BASE_URL') | default('', true) }}" OPENAI_BASE_API_URL: "{{ lookup('ansible.builtin.env', 'OPENAI_BASE_API_URL') | default('', true) }}" diff --git a/roles/vhosts/acp_codex/templates/acp-bridge.service.j2 b/roles/vhosts/acp_codex/templates/acp-bridge.service.j2 index ba88a3d..bdc9e0c 100644 --- a/roles/vhosts/acp_codex/templates/acp-bridge.service.j2 +++ b/roles/vhosts/acp_codex/templates/acp-bridge.service.j2 @@ -5,8 +5,8 @@ Wants=network-online.target [Service] Type=simple -User=root -Group=root +User={{ acp_codex_service_user }} +Group={{ acp_codex_service_group }} WorkingDirectory={{ acp_codex_workdir }} Environment=HOME={{ acp_codex_workdir }} Environment=TERM=xterm-256color diff --git a/roles/vhosts/acp_gemini/defaults/main.yml b/roles/vhosts/acp_gemini/defaults/main.yml index 7dfb4ec..43377be 100644 --- a/roles/vhosts/acp_gemini/defaults/main.yml +++ b/roles/vhosts/acp_gemini/defaults/main.yml @@ -1,9 +1,12 @@ --- acp_gemini_service_name: acp-gemini-adapter -acp_gemini_service_user: root -acp_gemini_service_group: root -acp_gemini_home: /root -acp_gemini_workdir: /root +acp_gemini_service_user: ubuntu +acp_gemini_service_group: "{{ acp_gemini_service_user }}" +acp_gemini_home: "/home/{{ acp_gemini_service_user }}" +acp_gemini_workdir: "{{ acp_gemini_home }}" +acp_gemini_xdg_config_home: "{{ acp_gemini_home }}/.config" +acp_gemini_xdg_state_home: "{{ acp_gemini_home }}/.local/state" +acp_gemini_config_dir: "{{ acp_gemini_home }}/.gemini" acp_gemini_binary_path: /usr/bin/gemini acp_gemini_args: --experimental-acp @@ -23,6 +26,9 @@ acp_gemini_public_base_url: https://acp-server.svc.plus/gemini acp_gemini_manage_caddy: false acp_gemini_auth_token: "{{ lookup('ansible.builtin.env', 'INTERNAL_SERVICE_TOKEN') | default('', true) }}" acp_gemini_environment: + XDG_CONFIG_HOME: "{{ acp_gemini_xdg_config_home }}" + XDG_STATE_HOME: "{{ acp_gemini_xdg_state_home }}" + GEMINI_CONFIG_DIR: "{{ acp_gemini_config_dir }}" GEMINI_ADAPTER_AUTH_TOKEN: "{{ acp_gemini_auth_token }}" ACP_GEMINI_BIN: "{{ acp_gemini_binary_path }}" acp_gemini_packages: diff --git a/roles/vhosts/acp_gemini/tasks/config.yml b/roles/vhosts/acp_gemini/tasks/config.yml index 4e73e4d..5404f6a 100644 --- a/roles/vhosts/acp_gemini/tasks/config.yml +++ b/roles/vhosts/acp_gemini/tasks/config.yml @@ -24,8 +24,8 @@ ansible.builtin.copy: src: "{{ acp_gemini_bridge_local_binary_path }}" dest: "{{ acp_gemini_bridge_binary_path }}" - owner: root - group: root + owner: "{{ acp_gemini_service_user }}" + group: "{{ acp_gemini_service_group }}" mode: "0755" notify: Restart gemini acp adapter diff --git a/roles/vhosts/xworkmate_bridge/README.md b/roles/vhosts/xworkmate_bridge/README.md index f47fb2c..3b1c9e5 100644 --- a/roles/vhosts/xworkmate_bridge/README.md +++ b/roles/vhosts/xworkmate_bridge/README.md @@ -57,6 +57,8 @@ Behavior after deployment: - requests with `Authorization: Bearer $INTERNAL_SERVICE_TOKEN` are accepted - this playbook only defines and validates the shared ingress token path - provider-specific authentication and ACP method compatibility are intentionally left to the individual runtimes +- the Codex runtime user is a role variable and defaults to `ubuntu`, so it can be changed from inventory if needed +- Gemini adapter is now also aligned to `ubuntu` home paths so it can reuse `/home/ubuntu/.gemini/oauth_creds.json` ## Public Endpoints