aoh

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

commit 1d7d70a82139e257f986c8bfb4278d1665abee5f
parent 49d5d4228d90e627e853ca5fa591f198d2c93410
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Fri, 21 Aug 2026 19:39:57 -0700

Add more playbook configuration options

Diffstat:
Maoh/__main__.py | 3++-
Aaoh/playbook.py | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Maoh/runner.py | 32+++++++++++++++++---------------
Maoh/server.py | 58+++++++---------------------------------------------------
4 files changed, 122 insertions(+), 67 deletions(-)

diff --git a/aoh/__main__.py b/aoh/__main__.py @@ -1,3 +1,4 @@ -from .server import app +from .server import app, scheduler +scheduler.start() app.run(debug=True) diff --git a/aoh/playbook.py b/aoh/playbook.py @@ -0,0 +1,96 @@ +import yaml + +CONFIG_FILE = 'config.yml' # TODO: make configurable + +DEFAULT_OPT_WHITELIST = [ + # These options should be safe for clients to invoke + '--check', '-C', + '--diff', '-D', + '--extra-vars', '-e', + '--help', '-h', + '--limit', '-l', + '--list-tags', + '--skip-tags', + '--start-at-task', + '--step', + '--tags', '-t', + '--verbose', '-v', + '--version', +] + +OPT_BLACKLIST = [ + # These options are not supported at all because they are interactive + '--step', + '-J', '--ask-vault-password', '--ask-vault-pass', + '-K', '--ask-become-pass', + '-c', '--connection,', + '-k', '--ask-pass', +] + + +class PlaybookError(Exception): + """Raised for AoH playbook configuration errors.""" + + +class Playbook: + def __init__(self, dict): + """Parse a playbook configuration from a dictionary.""" + + _types = { + 'playbook': str, + 'config': str, + 'cmdline': str, + 'output': bool, + 'jinja': bool, + 'allow_opts': list, # We'll just assume that elements are strings + 'block_opts': list, + } + + if 'playbook' not in dict: + raise PlaybookError('"playbook" field missing') + + for key, _type in _types.items(): + if key in dict and type(dict[key]) != _type: + raise PlaybookError(f'"{key}" field must be of type {_type}') + + self.playbook = dict['playbook'] + self.config = dict.get('config', None) + self.cmdline = dict.get('cmdline', '') + self.output = dict.get('output', True) + self.jinja = dict.get('jinja', False) + self.allow_opts = dict.get('allow_opts', []) + self.block_opts = dict.get('block_opts', []) + + + def validate_args(self, args): + """Determine if arguments pass the playbook's security policies.""" + + cmdline = ' '.join(args) + if not self.jinja and ('{{' in cmdline or '{%' in cmdline): + return False + + opts = [] + for arg in args: + if arg.startswith('--'): + opts += [arg.split('=')[0]] + elif arg.startswith('-'): + for opt in arg.split('=')[0][1:]: + opts += ['-' + opt] + + for opt in opts: + if (not (opt in DEFAULT_OPT_WHITELIST or opt in self.allow_opts) or + (opt in OPT_BLACKLIST or opt in self.block_opts)): + return False + + return True + + +def get_playbook(playbook): + """Lookup a playbook configuration.""" + + with open(CONFIG_FILE, 'r') as f: + CONFIG = yaml.safe_load(f) + if playbook in CONFIG: + return Playbook(CONFIG[playbook]) + else: + return None diff --git a/aoh/runner.py b/aoh/runner.py @@ -12,17 +12,18 @@ import ansible_runner _CONNECTION_PLUGIN_DIR = f'{os.path.dirname(__file__)}/connection_plugins/' _CLIENT_TIMEOUT = 600 # 10 minutes -class AoHError(Exception): - """Raised for AoH-specific errors.""" +class RunnerError(Exception): + """Raised for AoH runner errors.""" -class AoHRunner: +class Runner: """An Ansible-over-HTTP playbook runner.""" - def __init__(self, config, playbook, host, args): + def __init__(self, playbook, host, args): """Create a new AoH runner.""" self.id = str(uuid4()) # Runner ID (set to None after teardown) + self.playbook = playbook self._lock = Lock() # Used to protect all public methods @@ -44,7 +45,7 @@ class AoHRunner: with open(f'{self._DIR}/hostname', 'w') as f: f.write(f'{host}\n') - self._start_runner(config, playbook, host, args) + self._start_runner(host, args) # We assume that ansible-runner will eventually create its log file while not os.path.exists(self._LOGS_PATH): @@ -65,7 +66,7 @@ class AoHRunner: raise - def _start_runner(self, config, playbook, host, args): + def _start_runner(self, host, args): """Configure and start the underlying ansible-runner.""" # Set ansible_connection=aoh for the client host only. Note that we @@ -76,7 +77,7 @@ class AoHRunner: raw_config = ansible_runner.get_ansible_config( 'dump', - config, + self.playbook.config, private_data_dir=self._DIR, quiet=True, )[0] @@ -94,7 +95,7 @@ class AoHRunner: 'ANSIBLE_CONNECTION_PLUGINS': ':'.join( [_CONNECTION_PLUGIN_DIR] + connection_plugins ), - 'ANSIBLE_CONFIG': config, + 'ANSIBLE_CONFIG': self.playbook.config, 'ANSIBLE_INVENTORY': ','.join( [f'{self._DIR}/inventory.ini'] + inventory ), @@ -102,16 +103,16 @@ class AoHRunner: vars = { 'ansible_aoh_dir': self._DIR, } + cmdline = ' '.join(shlex.quote(arg) for arg in args) + cmdline += ' ' + self.playbook.cmdline self._thread, self._runner = ansible_runner.run_async( private_data_dir=self._DIR, ident=self.id, envvars=env, extravars=vars, - cmdline=' '.join(shlex.quote(arg) for arg in args), - - playbook=playbook, - + cmdline=cmdline, + playbook=self.playbook.playbook, quiet=True, suppress_env_files=True, ) @@ -148,8 +149,9 @@ class AoHRunner: # We check runner status first, in case new logs come in afterwards res['finished'] = self._runner_finished() - assert self._logs is not None - res['logs'] = self._logs.read() + if self.playbook.output: + assert self._logs is not None + res['logs'] = self._logs.read() if req and self._recvbuf: try: @@ -204,6 +206,6 @@ class AoHRunner: # broken recvbuf pipe or the sendbuf error. self._thread.join(10) if self._thread.is_alive(): - raise AoHError('Ansible runner thread not terminated') + raise RunnerError('Ansible runner thread not terminated') shutil.rmtree(self._DIR) diff --git a/aoh/server.py b/aoh/server.py @@ -1,10 +1,10 @@ import os -import yaml from flask import Flask, abort, make_response, render_template, request, session from flask_apscheduler import APScheduler -from .runner import AoHRunner +from .playbook import get_playbook +from .runner import Runner _RUNNERS = {} _GC_INTERVAL = 60 # 1 minute @@ -14,7 +14,6 @@ app.secret_key = os.urandom(16) scheduler = APScheduler() scheduler.init_app(app) -scheduler.start() @scheduler.task('interval', seconds=_GC_INTERVAL) @@ -28,45 +27,6 @@ def gc(): _RUNNERS.pop(id, None) -def _get_opts(args): - opts = [] - for arg in args: - if arg.startswith('--'): - opts += [arg.split('=')[0]] - elif arg.startswith('-'): - for opt in arg.split('=')[0][1:]: - opts += ['-' + opt] - return opts - - -def _validate_args(args): - # Reject any sign of Jinja expressions - if '{{' in ' '.join(args) or '{%' in ' '.join(args): - return False - - # Enforce whitelisted options. Other options either might not be supported - # yet, or may pose security risks. - OPT_WHITELIST = [ - '--check', '-C', - '--diff', '-D', - '--extra-vars', '-e', - '--help', '-h', - '--limit', '-l', - '--list-tags', - '--skip-tags', - '--start-at-task', - '--step', - '--tags', '-t', - '--verbose', '-v', - '--version', - ] - for opt in _get_opts(args): - if opt not in OPT_WHITELIST: - return False - - return True - - @app.get('/run') @app.get('/run.py') def install(): @@ -75,18 +35,14 @@ def install(): @app.post('/runners/') def new_runner(): - if not _validate_args(request.json['args']): - return { 'err': 'Bad or banned arguments passed.' }, 400 - - with open('config.yml', 'r') as f: - CONFIG = yaml.safe_load(f) - - playbook = CONFIG.get(request.json['playbook']) + playbook = get_playbook(request.json['playbook']) if not playbook: return { 'err': f"Playbook not found: {request.json['playbook']}" }, 400 - runner = AoHRunner(playbook['config'], playbook['playbook'], - request.json['host'], request.json['args']) + if not playbook.validate_args(request.json['args']): + return { 'err': 'Bad or banned arguments passed.' }, 400 + + runner = Runner(playbook, request.json['host'], request.json['args']) _RUNNERS[runner.id] = runner session['runner'] = runner.id