Add reusable multi-session skill and five-page project workbench
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Authenticated control client. No credentials are accepted on the command line."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class APIError(RuntimeError):
|
||||
def __init__(self,status):
|
||||
self.status=status
|
||||
super().__init__('Control API returned HTTP %s' % status)
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self,req,fp,code,msg,headers,newurl):
|
||||
return None
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def call(self, path, data=None, method=None):
|
||||
if not path.startswith('/api/') or '\n' in path:
|
||||
raise ValueError('Only control API paths are accepted')
|
||||
if self.config.get('transport') == 'ssh':
|
||||
cfg = self.config
|
||||
command = ['ssh', '-i', str(Path(cfg['identity_file']).expanduser()),
|
||||
'-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes',
|
||||
'-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10',
|
||||
cfg['ssh_host'], 'python3 -X utf8 ' + shlex.quote(cfg['remote_client']) +
|
||||
' --config ' + shlex.quote(cfg['remote_config']) + ' rpc']
|
||||
result = subprocess.run(command, input=json.dumps({'path': path, 'data': data, 'method': method}),
|
||||
capture_output=True, text=True, encoding='utf-8', errors='strict', timeout=45)
|
||||
if result.returncode:
|
||||
try:
|
||||
problem=json.loads(result.stdout)
|
||||
except ValueError:
|
||||
problem={}
|
||||
if set(problem)=={'api_error'} and isinstance(problem['api_error'],int):
|
||||
raise APIError(problem['api_error'])
|
||||
raise RuntimeError('SSH control request failed; check connection and remote configuration')
|
||||
return json.loads(result.stdout)
|
||||
cfg = self.config
|
||||
url = cfg.get('url') or ('http://%s:%s' % (cfg['bind'], cfg['port']))
|
||||
token_path = Path(cfg['token_file']).expanduser()
|
||||
if os.name != 'nt' and token_path.stat().st_mode & 0o077:
|
||||
raise ValueError('Token file must not be readable by group or other users')
|
||||
token = token_path.read_text(encoding='utf-8').strip()
|
||||
if not token:
|
||||
raise ValueError('Empty token file')
|
||||
body = None if data is None else json.dumps(data).encode()
|
||||
request = urllib.request.Request(url.rstrip('/') + path, data=body,
|
||||
headers={'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': '1'}, method=method or ('GET' if data is None else 'POST'))
|
||||
try:
|
||||
opener=urllib.request.build_opener(NoRedirect(),urllib.request.ProxyHandler({}))
|
||||
with opener.open(request, timeout=30) as response:
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
status = exc.code
|
||||
exc.close()
|
||||
raise APIError(status) from None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--config', required=True)
|
||||
parser.add_argument('action', choices=['state', 'rpc'])
|
||||
args = parser.parse_args()
|
||||
client = Client(json.loads(Path(args.config).read_text(encoding='utf-8-sig')))
|
||||
if args.action == 'state':
|
||||
result = client.call('/api/state')
|
||||
else:
|
||||
payload = json.load(sys.stdin)
|
||||
result = client.call(payload['path'], payload.get('data'), payload.get('method'))
|
||||
print(json.dumps(result, ensure_ascii=True))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except APIError as exc:
|
||||
print(json.dumps({'api_error':exc.status}))
|
||||
sys.exit(2)
|
||||
except Exception as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user