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:
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