aoh

A tool for running Ansible playbooks with `curl <url> | python` commands
git clone https://git.ashermorgan.net/aoh/
Log | Files | Refs | README

commit 29d8da98a32e78f76b13f6d5d07f0ee3f17b3c6b
parent d041637a4eba9a35e3644492bf6d036557d51da8
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Sat, 12 Sep 2026 17:09:47 -0700

Update README.md

Diffstat:
MREADME.md | 279+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Maoh/client.py | 4+++-
Ademo/another-playbook.yml | 2++
Mdemo/my-playbook.yml | 2+-
Mdemo/playbooks.yml | 45+++++++++++++++++++++++++--------------------
5 files changed, 198 insertions(+), 134 deletions(-)

diff --git a/README.md b/README.md @@ -1,6 +1,6 @@ # AoH: Ansible-over-HTTP -Run an Ansible playbook with a single `curl <url> | python` command. +Run an Ansible playbook with a single `curl <aoh server url> | python` command. More specifically, this triggers an `ansible-playbook` run on the AoH server, with the local client fetching commands for its own tasks over HTTP. This is @@ -13,109 +13,176 @@ installation playbook on new machines, anytime, anywhere. ## Comparison with Traditional Ansible & Ansible Pull -| | Ansible | AoH | Ansible Pull | -| --------------------------------- | -------------------- | -------------------- | ---------------- | -| Architecture | Push | Hybrid | Pull | -| Server requirements | `ansible-playbook` | `aoh` | Any git server | -| Client requirements | `python` | `python` | `ansible-pull` | -| Controller logic execution | Server-side | Server-side | Client-side | -| Client has full repository access | No | No | Yes | -| Connection method | Usually SSH | HTTP | Local connection | -| Connection direction | Server &rarr; client | Client &rarr; server | NA | +| | Ansible | AoH | Ansible Pull | +| --------------------------------- | ------------------ | ----------- | ---------------- | +| Architecture | Push | Pull | Pull | +| Server requirements | `ansible-playbook` | `aoh` | Any git server | +| Client requirements | `python` | `python` | `ansible-pull` | +| Ansible controller | Server | Server | Client | +| Client has full repository access | No | No | Yes | +| Connection method | Usually SSH | HTTP | Local connection | + +Note that Ansible controllers don't support Windows, so the `ansible-playbook`, +`aoh`, and `ansible-pull` programs all must be run on a Unix-based system. ## Getting Started -The AoH server is packaged as a Docker image. You can quickly try it out with -the following commands: +First, build and run the AoH server docker image. + +``` +$ docker build -t aoh . +$ docker run --rm --detach --name aoh -p 8000:8000 -v ./demo:/aoh aoh +``` + +Next, run the [demo playbook](demo/my-playbook.yml) via the client Python +script. The demo AoH password is `hunter2`. + +``` +$ curl -s 127.0.0.1:8000/run | python3 +AoH password: + +PLAY [Demo play] *************************************************************** + +TASK [Gathering Facts] ********************************************************* +ok: [my-host] + +TASK [Write message to ~/aoh-demo.txt] ***************************************** +--- before ++++ after: /home/me/.ansible/tmp/ansible-local-23520hhz8owqd/tmpddlm2qex/message.j2 +@@ -0,0 +1,8 @@ ++Welcome to AoH! ++ ++The server is running this playbook with ansible-playbook, and your client is ++fetching commands for its own tasks over HTTP. ++ ++Username: me ++Hostname: my-host ++Timestamp: 2026-09-12 22:58:10 UTC + +changed: [my-host] + +PLAY RECAP ********************************************************************* +my-host : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +Clients can pass options to control which playbook is executed and what +`ansible-playbook` arguments are used. Use the `--help` flag to view the CLI +syntax. + +``` +$ curl -s 127.0.0.1:8000/run | python3 - --help +Usage: curl -s http://127.0.0.1:8000/run | python3 - [-h] [<playbook>] [<opts>...] + +Runs Ansible playbooks on a remote server and fetches commands for the local +host's tasks over HTTP. + +Arguments: + playbook The name of the playbook to run (defaults to "main.yml") -```sh -# Build and run the server docker image: -docker build -t aoh . -docker run --rm --detach --name aoh -p 8000:8000 -v ./demo:/aoh aoh +Options: + -h, --help Show this help message and exit + <opts> Any (server-approved) ansible-playbook(1) options +``` -# Run the client CLI (the demo AoH password is "hunter2") -curl -s 127.0.0.1:8000/run | python3 -curl -s 127.0.0.1:8000/run | python3 - --help +Windows clients are also supported. Just use `Invoke-WebRequest` instead of +`curl`. -# Stop the server docker container -docker stop aoh ``` +PS> (Invoke-WebRequest http://<server IP>:8000).Content | python +``` + +Finally, stop the AoH server when you're done using it. + +``` +$ docker stop aoh +``` + +Next steps for deploying AoH include: + +- Copy your own Ansible playbooks to the server +- Update the `playbooks.yml` and `passwords.yml` files that are mounted under + `/aoh/` (see the [playbooks](#playbooks) and [playbook + passwords](#playbook-passwords) sections for reference) +- Apply the recommended [security measures](#security) ## Configuration ### Environment Variables -AoH supports the following configuration options, specified either via -environment variables or a `.env` file. +AoH supports the following configuration options, which are set via environment +variables. -- `AOH_DEBUG`: If set to `1`, the AoH server will copy Ansible logs to stdout. - Defaults to `0`. +- `AOH_DEBUG`: If set to `1`, the AoH server will copy `ansible-playbook` output + to the server's stdout. Defaults to `0`. -- `AOH_LOG_LEVEL`: The AoH logging threshold. Defaults to `ERROR`. +- `AOH_LOG_LEVEL`: The threshold for AoH logs, which are written to the server's + stdout. Must be one of `CRITICAL`, `ERROR`, `WARNING`, `INFO`, or `DEBUG`. + Defaults to `ERROR`. -- `AOH_MAX_RUNNERS`: The maximum number of concurrent runners, or `0` for no - limit. Defaults to `0`. +- `AOH_MAX_RUNNERS`: The maximum number of concurrent `ansible-playbook` + sessions, or `0` for no limit. Defaults to `0`. -- `AOH_ORIGIN`: The origin of the AoH server, as referenced in the `/run.py` - script (e.g. `https://aoh.example.com:5000`). This must be set if running AoH - behind a proxy. Defaults to the origin on which requests for `/run.py` are - received. +- `AOH_ORIGIN`: The origin of the AoH server (e.g. + `https://aoh.example.com:5000`). This must be set if AoH is running behind a + reverse proxy. Defaults to the origin on which requests are received. - `AOH_PASSWORDS_FILE`: The file containing passwords for password-protected - playbooks (see below). Defaults to `/aoh/passwords.yml` when running the - Docker image and `./passwords.yml` otherwise. + playbooks (see the [playbook passwords](#playbook-passwords) section below). + Defaults to `/aoh/passwords.yml` when running the Docker image and + `./passwords.yml` otherwise. -- `AOH_PLAYBOOKS_FILE`: The file containing playbook configuration (see below). - Defaults to `/aoh/playbooks.yml` when running the Docker image and - `./playbooks.yml` otherwise. +- `AOH_PLAYBOOKS_FILE`: The file containing playbook configuration (see the + [playbooks](#playbooks) section below). Defaults to `/aoh/playbooks.yml` when + running the Docker image and `./playbooks.yml` otherwise. ### Playbooks -AoH only executes playbooks that are listed in the `$AOH_PLAYBOOKS_FILE` file, -which must have the following structure: +AoH only executes playbooks that are listed in the file specified by +the `$AOH_PLAYBOOKS_FILE` option. This file must have the following structure: <!-- EXAMPLE COPIED FROM demo/playbooks.yml: --> ```yml main.yml: # The playbook name - # The path to the playbook, relative to $AOH_PLAYBOOKS_FILE. Defaults to the - # playbook name. - path: playbooks/playbook.yml + # The path to the playbook. Relative paths are interpreted as relative to + # $AOH_PLAYBOOKS_FILE. Defaults to the playbook name. + path: my-playbook.yml - # The path to an associated Ansible config file, if one exists, relative to - # $AOH_PLAYBOOKS_FILE. - config: playbooks/ansible.cfg + # The path to an associated Ansible config file, if one exists. Relative paths + # are interpreted as relative to $AOH_PLAYBOOKS_FILE. + config: ansible.cfg - # The hostname to assign to clients. If omitted or null, each client is - # assigned the hostname that it reports for itself. - host: my-aoh-host + # The hostname to assign to clients. If set to null, clients are assigned the + # hostname that they report for themselves, which could be spoofed. Defaults + # to null. + host: my-host - # The groups to assign to clients, in addition to the aoh group and any groups - # defined in inventory files. + # The groups to assign to clients, in addition to the "aoh" group and any + # groups defined in inventory files. groups: - group1 - group2 - # Whether to restrict play execution to the AoH client using + # Whether to restrict play execution to the AoH client only using # ansible-playbook's --limit option. If enabled, the --limit option must not # be present in allow_opts or extra_args. Defaults to true. limit: true # Whether to require a password to execute the playbook. Passwords must be - # specified separately in the $AOH_PASSWORDS_FILE file (see below). Defaults - # to false. + # specified separately in the $AOH_PASSWORDS_FILE file (see the playbook + # passwords section below). Defaults to false. password: true - # Whether to print Ansible output on the client. Defaults to true. + # Whether ansible-playbook output is sent to the client. Defaults to true. output: true - # The ansible-playbooks options that users are allowed to invoke. Short and - # long option forms must be specified separately. The following options are - # allowed by default: + # A list of ansible-playbooks options that users are allowed to invoke. Short + # and long option forms must be specified separately. The following options + # are allowed by default: # --ask-become-pass / -K # --ask-pass / -k # --ask-vault-password / --ask-vault-pass / -J @@ -135,22 +202,26 @@ main.yml: # The playbook name # Whether to allow clients to use Jinja expressions in ansible-playbook # arguments. Defaults to false. - jinja_args: true + jinja_args: false - # Additional raw ansible-playbook options + # A list of additional arguments to pass to ansible-playbook. These are not + # subject to the allow_opts/block_opts/jinja_args restrictions. extra_args: - '--diff' - # The description displayed on the playbook web page. Defaults to null, which - # disables the playbook web page. + # The description displayed on the playbook web page, which is located at + # /<playbook name>. Defaults to null, which disables the playbook web page. web_description: 'Run the main.yml playbook with a single command:' + +another-playbook.yml: + # and so on... ``` ### Playbook Passwords -Passwords for password-protected playbooks must be specified as bcrypt hashes in -the `$AOH_PASSWORDS_FILE` file. For example: +Passwords for password-protected playbooks must be included as bcrypt hashes in +the file specified by the `$AOH_PASSWORDS_FILE` option. For example: <!-- EXAMPLE COPIED FROM demo/passwords.yml: --> @@ -159,57 +230,41 @@ the `$AOH_PASSWORDS_FILE` file. For example: main.yml: $2a$14$LfC6Bcczc0WM.OObqgQPYeVvYa10g1Z4Z8C.eERyd.GarPmNa5/ve ``` +There are many tools for generating bcrypt hashes. Here is a one-liner that uses +the same `bcrypt` Python library that AoH depends on internally: + +``` +$ python -c 'import bcrypt, getpass; print(bcrypt.hashpw(getpass.getpass().encode(), bcrypt.gensalt()).decode())' +``` + ## Security -Like both Ansible and Ansible Pull, AoH clients must trust the server and its -playbooks since they are allowed to execute arbitrary commands. Unlike both -Ansible and Ansible Pull however, AoH introduces the risk of clients influencing -command execution on the server. As just one example, if a malicious client -passed the options `--extra-var "foo={{ lookup('file', '/secret' }}"` to AoH and -the variable `foo` was used in a task run on the client, the contents of -`/secret` could be leaked to the client. - -AoH aims to minimize these security risks as much as possible while still -providing flexibility for different use cases. Below are the measures that AoH -takes, or can be configured to take, to mitigate security risks. - -- **Restricting Playbook Access:** By default, any client with access to the AoH - server can run any playbook. If access must be restricted to certain clients, - playbooks should be password-protected using the `password` playbook option - and the `$AOH_PASSWORDS_FILE` file. - -- **Blocking Ansible Options:** By default, clients are only allowed to invoke - only a small subset of reasonably safe `ansible-playbook` options. Additional - options can be allowed or blocked via the `allow_opts` and `block_opts` - playbook options. - -- **Blocking Jinja Expressions in Ansible Arguments:** By default, - clients-supplied `ansible-playbook` options containing Jinja expressions are - blocked. If necessary, this protection can be disabled via the `jinja_args` - playbook option. - -- **Hiding Ansible Output:** By default, clients receives and print all Ansible - output, including `--diff` output, `--verbose` logs, and even output from - tasks executed on other hosts. If this behavior could reveal sensitive data, - Ansible output should be hidden entirely from clients via the `output` - playbook option. - -- **Forcing Client Hostnames:** By default, the server executes tasks on clients - according to their reported hostnames. This could allow a client to receive - playbook tasks meant for other hosts. If a playbook includes sensitive tasks - for other hosts, client hostnames should be set to a safe value via the `host` - playbook option. - -- **Forcing Ansible Options:** Additional `ansible-playbook` options may be - added to every execution via the `extra_args` playbook option. For example, if - clients must not be able to trigger the execution of other hosts' tasks, then - the `['--limit', 'aoh']` option should be added to `extra_args`. Consider also - adding these options to `block_opts` to prevent clients from modifying them. - -Other recommended security measures include using a reverse proxy to serve AoH -over HTTPS only, using Ansible Vault to securely store playbook secrets, and -ensuring that all playbooks are fully trusted and secure. +Like both Ansible and Ansible Pull, AoH clients must completely trust the server +and its playbooks, since they are allowed to execute arbitrary commands. AoH +does not attempt to protect clients from malicious servers. + +Unlike both Ansible and Ansible Pull however, AoH introduces the risk of clients +influencing the execution of commands on the server. AoH implements various +protections against this risk by default, including blocking potentially unsafe +`ansible-playbook` arguments and limiting play execution to the AoH client. +Additional protections are available via playbook options such as `host`, +`password`, and `output`. + +The following security measures are recommended as a baseline when deploying +AoH: + +- Ensure all Ansible playbooks are trusted and do not contain any potential + security holes +- Run the AoH server behind a reverse proxy that enforces HTTPS (and set + the `$AOH_ORIGIN` option accordingly) +- Restrict access for each playbook to authorized clients using the `password` + playbook option +- Encrypt sensitive variables using Ansible Vault and require clients to supply + the decryption password via Ansible's `--ask-vault-pass` option +- If clients are untrusted and an Ansible playbook contains tasks for multiple + hosts, create separate entries in the AoH playbooks file for each host with + the `host` option set explicitly to prevent hostname spoofing ## Limitations @@ -218,6 +273,6 @@ ensuring that all playbooks are fully trusted and secure. `ansible_connection` option via a custom inventory host variable. This variable must not be overridden by, for example, variables in `host_vars/*` files. Refer to Ansible's [variable precedence - documentation][ansible-precedence] for more information. + documentation][ansible-precedence] for more details. [ansible-precedence]: https://docs.ansible.com/projects/ansible/latest/playbook_guide/playbooks_variables.html#understanding-variable-precedence diff --git a/aoh/client.py b/aoh/client.py @@ -303,6 +303,8 @@ def cli(args): prog = f'curl -s {AOH_ORIGIN}/run | ' \ f'{os.path.basename(sys.executable)} -' + # Note: changes to this message should also be applied to README.md + print(f'Usage: {prog} [-h] [<playbook>] [<opts>...]') print() print('Runs Ansible playbooks on a remote server and fetches commands ' @@ -315,7 +317,7 @@ def cli(args): print() print('Options: ') print(' -h, --help Show this help message and exit') - print(' <opts> Any server-approved ansible-playbook(1) ' + print(' <opts> Any (server-approved) ansible-playbook(1) ' 'options') sys.exit(0) diff --git a/demo/another-playbook.yml b/demo/another-playbook.yml @@ -0,0 +1,2 @@ +- name: A no-op play + hosts: all diff --git a/demo/my-playbook.yml b/demo/my-playbook.yml @@ -1,4 +1,4 @@ -- name: My play +- name: Demo play hosts: all tasks: - name: Write message to ~/aoh-demo.txt diff --git a/demo/playbooks.yml b/demo/playbooks.yml @@ -1,39 +1,40 @@ main.yml: # The playbook name - # The path to the playbook, relative to $AOH_PLAYBOOKS_FILE. Defaults to the - # playbook name. + # The path to the playbook. Relative paths are interpreted as relative to + # $AOH_PLAYBOOKS_FILE. Defaults to the playbook name. path: my-playbook.yml - # The path to an associated Ansible config file, if one exists, relative to - # $AOH_PLAYBOOKS_FILE. + # The path to an associated Ansible config file, if one exists. Relative paths + # are interpreted as relative to $AOH_PLAYBOOKS_FILE. config: ansible.cfg - # The hostname to assign to clients. If omitted or null, each client is - # assigned the hostname that it reports for itself. - host: my-aoh-host + # The hostname to assign to clients. If set to null, clients are assigned the + # hostname that they report for themselves, which could be spoofed. Defaults + # to null. + host: my-host - # The groups to assign to clients, in addition to the aoh group and any groups - # defined in inventory files. + # The groups to assign to clients, in addition to the "aoh" group and any + # groups defined in inventory files. groups: - group1 - group2 - # Whether to restrict play execution to the AoH client using + # Whether to restrict play execution to the AoH client only using # ansible-playbook's --limit option. If enabled, the --limit option must not # be present in allow_opts or extra_args. Defaults to true. limit: true # Whether to require a password to execute the playbook. Passwords must be - # specified separately in the $AOH_PASSWORDS_FILE file (see below). Defaults - # to false. + # specified separately in the $AOH_PASSWORDS_FILE file (see the playbook + # passwords section below). Defaults to false. password: true - # Whether to print Ansible output on the client. Defaults to true. + # Whether ansible-playbook output is sent to the client. Defaults to true. output: true - # The ansible-playbooks options that users are allowed to invoke. Short and - # long option forms must be specified separately. The following options are - # allowed by default: + # A list of ansible-playbooks options that users are allowed to invoke. Short + # and long option forms must be specified separately. The following options + # are allowed by default: # --ask-become-pass / -K # --ask-pass / -k # --ask-vault-password / --ask-vault-pass / -J @@ -53,12 +54,16 @@ main.yml: # The playbook name # Whether to allow clients to use Jinja expressions in ansible-playbook # arguments. Defaults to false. - jinja_args: true + jinja_args: false - # Additional raw ansible-playbook options + # A list of additional arguments to pass to ansible-playbook. These are not + # subject to the allow_opts/block_opts/jinja_args restrictions. extra_args: - '--diff' - # The description displayed on the playbook web page. Defaults to null, which - # disables the playbook web page. + # The description displayed on the playbook web page, which is located at + # /<playbook name>. Defaults to null, which disables the playbook web page. web_description: 'Run the main.yml playbook with a single command:' + +another-playbook.yml: + # and so on...