pywebexec 1.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pywebexec/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """ package pywebexec """
2
+
3
+ __author__ = "Franck Jouvanceau"
4
+
5
+ from .pywebexec import start_gunicorn
pywebexec/pywebexec.py ADDED
@@ -0,0 +1,595 @@
1
+ import sys
2
+ from flask import Flask, request, jsonify, render_template, session, redirect, url_for
3
+ from flask_httpauth import HTTPBasicAuth
4
+ import subprocess
5
+ import threading
6
+ import os
7
+ import json
8
+ import uuid
9
+ import argparse
10
+ import random
11
+ import string
12
+ from datetime import datetime, timezone, timedelta
13
+ import shlex
14
+ from gunicorn.app.base import Application
15
+ import ipaddress
16
+ from socket import gethostname, gethostbyname_ex
17
+ import ssl
18
+ import re
19
+ if os.environ.get('PYWEBEXEC_LDAP_SERVER'):
20
+ from ldap3 import Server, Connection, ALL, SIMPLE, SUBTREE, Tls
21
+
22
+ app = Flask(__name__)
23
+ app.secret_key = os.urandom(24) # Secret key for session management
24
+ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Add SameSite attribute to session cookies
25
+ auth = HTTPBasicAuth()
26
+
27
+ app.config['LDAP_SERVER'] = os.environ.get('PYWEBEXEC_LDAP_SERVER')
28
+ app.config['LDAP_USER_ID'] = os.environ.get('PYWEBEXEC_LDAP_USER_ID', "uid")
29
+ app.config['LDAP_GROUPS'] = os.environ.get('PYWEBEXEC_LDAP_GROUPS')
30
+ app.config['LDAP_BASE_DN'] = os.environ.get('PYWEBEXEC_LDAP_BASE_DN')
31
+ app.config['LDAP_BIND_DN'] = os.environ.get('PYWEBEXEC_LDAP_BIND_DN')
32
+ app.config['LDAP_BIND_PASSWORD'] = os.environ.get('PYWEBEXEC_LDAP_BIND_PASSWORD')
33
+
34
+ # Directory to store the command status and output
35
+ COMMAND_STATUS_DIR = '.web_status'
36
+ CONFDIR = os.path.expanduser("~/")
37
+ if os.path.isdir(f"{CONFDIR}/.config"):
38
+ CONFDIR += '/.config'
39
+ CONFDIR += "/.pywebexec"
40
+
41
+
42
+ # In-memory cache for command statuses
43
+ command_status_cache = {}
44
+
45
+ def generate_random_password(length=12):
46
+ characters = string.ascii_letters + string.digits + string.punctuation
47
+ return ''.join(random.choice(characters) for i in range(length))
48
+
49
+
50
+ def resolve_hostname(host):
51
+ """try get fqdn from DNS"""
52
+ try:
53
+ return gethostbyname_ex(host)[0]
54
+ except OSError:
55
+ return host
56
+
57
+
58
+ def generate_selfsigned_cert(hostname, ip_addresses=None, key=None):
59
+ """Generates self signed certificate for a hostname, and optional IP addresses.
60
+ from: https://gist.github.com/bloodearnest/9017111a313777b9cce5
61
+ """
62
+ from cryptography import x509
63
+ from cryptography.x509.oid import NameOID
64
+ from cryptography.hazmat.primitives import hashes
65
+ from cryptography.hazmat.backends import default_backend
66
+ from cryptography.hazmat.primitives import serialization
67
+ from cryptography.hazmat.primitives.asymmetric import rsa
68
+
69
+ # Generate our key
70
+ if key is None:
71
+ key = rsa.generate_private_key(
72
+ public_exponent=65537,
73
+ key_size=2048,
74
+ backend=default_backend(),
75
+ )
76
+
77
+ name = x509.Name([
78
+ x509.NameAttribute(NameOID.COMMON_NAME, hostname)
79
+ ])
80
+
81
+ # best practice seem to be to include the hostname in the SAN, which *SHOULD* mean COMMON_NAME is ignored.
82
+ alt_names = [x509.DNSName(hostname)]
83
+ alt_names.append(x509.DNSName("localhost"))
84
+
85
+ # allow addressing by IP, for when you don't have real DNS (common in most testing scenarios
86
+ if ip_addresses:
87
+ for addr in ip_addresses:
88
+ # openssl wants DNSnames for ips...
89
+ alt_names.append(x509.DNSName(addr))
90
+ # ... whereas golang's crypto/tls is stricter, and needs IPAddresses
91
+ # note: older versions of cryptography do not understand ip_address objects
92
+ alt_names.append(x509.IPAddress(ipaddress.ip_address(addr)))
93
+ san = x509.SubjectAlternativeName(alt_names)
94
+
95
+ # path_len=0 means this cert can only sign itself, not other certs.
96
+ basic_contraints = x509.BasicConstraints(ca=True, path_length=0)
97
+ now = datetime.now(timezone.utc)
98
+ cert = (
99
+ x509.CertificateBuilder()
100
+ .subject_name(name)
101
+ .issuer_name(name)
102
+ .public_key(key.public_key())
103
+ .serial_number(1000)
104
+ .not_valid_before(now)
105
+ .not_valid_after(now + timedelta(days=10*365))
106
+ .add_extension(basic_contraints, False)
107
+ .add_extension(san, False)
108
+ .sign(key, hashes.SHA256(), default_backend())
109
+ )
110
+ cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
111
+ key_pem = key.private_bytes(
112
+ encoding=serialization.Encoding.PEM,
113
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
114
+ encryption_algorithm=serialization.NoEncryption(),
115
+ )
116
+
117
+ return cert_pem, key_pem
118
+
119
+
120
+
121
+ class StandaloneApplication(Application):
122
+
123
+ def __init__(self, app, options=None):
124
+ self.options = options or {}
125
+ self.application = app
126
+ super().__init__()
127
+
128
+ def load_config(self):
129
+ config = {
130
+ key: value for key, value in self.options.items()
131
+ if key in self.cfg.settings and value is not None
132
+ }
133
+ for key, value in config.items():
134
+ self.cfg.set(key.lower(), value)
135
+
136
+ def load(self):
137
+ return self.application
138
+
139
+
140
+ ANSI_ESCAPE = re.compile(br'(?:\x1B[@-Z\\-_]|\x1B([(]B|>)|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~]|\x1B\[[0-9]{1,2};[0-9]{1,2}[m|K]|\x1B\[[0-9;]*[mGKHF]|[\x00-\x1F\x7F])')
141
+
142
+ def strip_ansi_control_chars(text):
143
+ """Remove ANSI and control characters from the text."""
144
+ return ANSI_ESCAPE.sub(b'', text)
145
+
146
+
147
+ def decode_line(line: bytes) -> str:
148
+ """try decode line exception on binary"""
149
+ try:
150
+ return strip_ansi_control_chars(line).decode().strip(" ")
151
+ except UnicodeDecodeError:
152
+ return ""
153
+
154
+
155
+ def last_line(fd, maxline=1000):
156
+ """last non empty line of file"""
157
+ line = "\n"
158
+ fd.seek(0, os.SEEK_END)
159
+ size = 0
160
+ last_pos = 0
161
+ while line in ["", "\n", "\r"] and size < maxline:
162
+ try: # catch if file empty / only empty lines
163
+ if last_pos:
164
+ fd.seek(last_pos-2, os.SEEK_SET)
165
+ while fd.read(1) not in [b"\n", b"\r"]:
166
+ fd.seek(-2, os.SEEK_CUR)
167
+ size += 1
168
+ except OSError:
169
+ fd.seek(0)
170
+ line = decode_line(fd.readline())
171
+ break
172
+ last_pos = fd.tell()
173
+ line = decode_line(fd.readline())
174
+ return line.strip()
175
+
176
+
177
+ def get_last_non_empty_line_of_file(file_path):
178
+ """Get the last non-empty line of a file."""
179
+ with open(file_path, 'rb') as f:
180
+ return last_line(f)
181
+
182
+
183
+ def start_gunicorn(daemon=False, baselog=None):
184
+ if daemon:
185
+ errorlog = f"{baselog}.log"
186
+ accesslog = None # f"{baselog}.access.log"
187
+ pidfile = f"{baselog}.pid"
188
+ else:
189
+ errorlog = "-"
190
+ accesslog = "-"
191
+ pidfile = None
192
+ options = {
193
+ 'bind': '%s:%s' % (args.listen, args.port),
194
+ 'workers': 4,
195
+ 'timeout': 600,
196
+ 'certfile': args.cert,
197
+ 'keyfile': args.key,
198
+ 'daemon': daemon,
199
+ 'errorlog': errorlog,
200
+ 'accesslog': accesslog,
201
+ 'pidfile': pidfile,
202
+ }
203
+ StandaloneApplication(app, options=options).run()
204
+
205
+ def daemon_d(action, pidfilepath, hostname=None, args=None):
206
+ """start/stop daemon"""
207
+ import signal
208
+ import daemon, daemon.pidfile
209
+
210
+ pidfile = daemon.pidfile.TimeoutPIDLockFile(pidfilepath+".pid", acquire_timeout=30)
211
+ if action == "stop":
212
+ if pidfile.is_locked():
213
+ pid = pidfile.read_pid()
214
+ print(f"Stopping server pid {pid}")
215
+ try:
216
+ os.kill(pid, signal.SIGINT)
217
+ except:
218
+ return False
219
+ return True
220
+ elif action == "status":
221
+ status = pidfile.is_locked()
222
+ if status:
223
+ print(f"pywebexec running pid {pidfile.read_pid()}")
224
+ return True
225
+ print("pywebexec not running")
226
+ return False
227
+ elif action == "start":
228
+ print(f"Starting server")
229
+ log = open(pidfilepath + ".log", "ab+")
230
+ daemon_context = daemon.DaemonContext(
231
+ stderr=log,
232
+ pidfile=pidfile,
233
+ umask=0o077,
234
+ working_directory=os.getcwd(),
235
+ )
236
+ with daemon_context:
237
+ try:
238
+ start_gunicorn()
239
+ except Exception as e:
240
+ print(e)
241
+
242
+ def parseargs():
243
+ global app, args
244
+ parser = argparse.ArgumentParser(description='Run the command execution server.')
245
+ parser.add_argument('-u', '--user', help='Username for basic auth')
246
+ parser.add_argument('-P', '--password', help='Password for basic auth')
247
+ parser.add_argument(
248
+ "-l", "--listen", type=str, default="0.0.0.0", help="HTTP server listen address"
249
+ )
250
+ parser.add_argument(
251
+ "-p", "--port", type=int, default=8080, help="HTTP server listen port"
252
+ )
253
+ parser.add_argument(
254
+ "-d", "--dir", type=str, default=os.getcwd(), help="Serve target directory"
255
+ )
256
+ parser.add_argument(
257
+ "-t",
258
+ "--title",
259
+ type=str,
260
+ default="PyWebExec",
261
+ help="Web html title",
262
+ )
263
+ parser.add_argument("-c", "--cert", type=str, help="Path to https certificate")
264
+ parser.add_argument("-k", "--key", type=str, help="Path to https certificate key")
265
+ parser.add_argument("-g", "--gencert", action="store_true", help="https server self signed cert")
266
+ parser.add_argument("action", nargs="?", help="daemon action start/stop/restart/status", choices=["start","stop","restart","status"])
267
+
268
+ args = parser.parse_args()
269
+ if os.path.isdir(args.dir):
270
+ try:
271
+ os.chdir(args.dir)
272
+ except OSError:
273
+ print(f"Error: cannot chdir {args.dir}", file=sys.stderr)
274
+ sys.exit(1)
275
+ else:
276
+ print(f"Error: {args.dir} not found", file=sys.stderr)
277
+ sys.exit(1)
278
+ if not os.path.exists(COMMAND_STATUS_DIR):
279
+ os.makedirs(COMMAND_STATUS_DIR)
280
+ if args.gencert:
281
+ hostname = resolve_hostname(gethostname())
282
+ args.cert = args.cert or f"{CONFDIR}/pywebexec.crt"
283
+ args.key = args.key or f"{CONFDIR}/pywebexec.key"
284
+ if not os.path.exists(args.cert):
285
+ (cert, key) = generate_selfsigned_cert(hostname)
286
+ with open(args.cert, "wb") as fd:
287
+ fd.write(cert)
288
+ with open(args.key, "wb") as fd:
289
+ fd.write(key)
290
+
291
+ if args.user:
292
+ app.config['USER'] = args.user
293
+ if args.password:
294
+ app.config['PASSWORD'] = args.password
295
+ else:
296
+ app.config['PASSWORD'] = generate_random_password()
297
+ print(f'Generated password for user {args.user}: {app.config["PASSWORD"]}')
298
+ else:
299
+ app.config['USER'] = None
300
+ app.config['PASSWORD'] = None
301
+
302
+ return args
303
+
304
+ parseargs()
305
+
306
+ def get_status_file_path(command_id):
307
+ return os.path.join(COMMAND_STATUS_DIR, f'{command_id}.json')
308
+
309
+ def get_output_file_path(command_id):
310
+ return os.path.join(COMMAND_STATUS_DIR, f'{command_id}_output.txt')
311
+
312
+ def update_command_status(command_id, status, command=None, params=None, start_time=None, end_time=None, exit_code=None, pid=None, user=None):
313
+ status_file_path = get_status_file_path(command_id)
314
+ status_data = read_command_status(command_id) or {}
315
+ status_data['status'] = status
316
+ if command is not None:
317
+ status_data['command'] = command
318
+ if params is not None:
319
+ status_data['params'] = params
320
+ if start_time is not None:
321
+ status_data['start_time'] = start_time
322
+ if end_time is not None:
323
+ status_data['end_time'] = end_time
324
+ if exit_code is not None:
325
+ status_data['exit_code'] = exit_code
326
+ if pid is not None:
327
+ status_data['pid'] = pid
328
+ if user is not None:
329
+ status_data['user'] = user
330
+ if status != 'running':
331
+ output_file_path = get_output_file_path(command_id)
332
+ if os.path.exists(output_file_path):
333
+ status_data['last_output_line'] = get_last_non_empty_line_of_file(output_file_path)
334
+ with open(status_file_path, 'w') as f:
335
+ json.dump(status_data, f)
336
+
337
+ # Update cache if status is not "running"
338
+ if status != 'running':
339
+ command_status_cache[command_id] = status_data
340
+ elif command_id in command_status_cache:
341
+ del command_status_cache[command_id]
342
+
343
+ def read_command_status(command_id):
344
+ # Return cached status if available
345
+ if command_id in command_status_cache:
346
+ return command_status_cache[command_id]
347
+
348
+ status_file_path = get_status_file_path(command_id)
349
+ if not os.path.exists(status_file_path):
350
+ return None
351
+ with open(status_file_path, 'r') as f:
352
+ try:
353
+ status_data = json.load(f)
354
+ except json.JSONDecodeError:
355
+ return None
356
+
357
+ # Cache the status if it is not "running"
358
+ if status_data['status'] != 'running':
359
+ command_status_cache[command_id] = status_data
360
+
361
+ return status_data
362
+
363
+ # Dictionary to store the process objects
364
+ processes = {}
365
+
366
+ def run_command(command, params, command_id, user):
367
+ start_time = datetime.now().isoformat()
368
+ update_command_status(command_id, 'running', command=command, params=params, start_time=start_time, user=user)
369
+ try:
370
+ output_file_path = get_output_file_path(command_id)
371
+ with open(output_file_path, 'w') as output_file:
372
+ # Run the command with parameters and redirect stdout and stderr to the file
373
+ process = subprocess.Popen([command] + params, stdout=output_file, stderr=output_file, bufsize=0) #text=True)
374
+ update_command_status(command_id, 'running', pid=process.pid, user=user)
375
+ processes[command_id] = process
376
+ process.wait()
377
+ processes.pop(command_id, None)
378
+
379
+ end_time = datetime.now().isoformat()
380
+ # Update the status based on the result
381
+ if process.returncode == 0:
382
+ update_command_status(command_id, 'success', end_time=end_time, exit_code=process.returncode, user=user)
383
+ elif process.returncode == -15:
384
+ update_command_status(command_id, 'aborted', end_time=end_time, exit_code=process.returncode, user=user)
385
+ else:
386
+ update_command_status(command_id, 'failed', end_time=end_time, exit_code=process.returncode, user=user)
387
+ except Exception as e:
388
+ end_time = datetime.now().isoformat()
389
+ update_command_status(command_id, 'failed', end_time=end_time, exit_code=1, user=user)
390
+ with open(get_output_file_path(command_id), 'a') as output_file:
391
+ output_file.write(str(e))
392
+
393
+ @app.before_request
394
+ def check_authentication():
395
+ if not app.config['USER'] and not app.config['LDAP_SERVER']:
396
+ return
397
+ if 'username' not in session and request.endpoint not in ['login', 'static']:
398
+ return auth.login_required(lambda: None)()
399
+
400
+ @auth.verify_password
401
+ def verify_password(username, password):
402
+ if not username:
403
+ session['username'] = '-'
404
+ return False
405
+ if app.config['USER']:
406
+ if username == app.config['USER'] and password == app.config['PASSWORD']:
407
+ session['username'] = username
408
+ return True
409
+ elif app.config['LDAP_SERVER']:
410
+ if verify_ldap(username, password):
411
+ session['username'] = username
412
+ return True
413
+ return False
414
+
415
+ def verify_ldap(username, password):
416
+ tls_configuration = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2) if app.config['LDAP_SERVER'].startswith("ldaps:") else None
417
+ server = Server(app.config['LDAP_SERVER'], tls=tls_configuration, get_info=ALL)
418
+ user_filter = f"({app.config['LDAP_USER_ID']}={username})"
419
+ try:
420
+ # Bind with the bind DN and password
421
+ conn = Connection(server, user=app.config['LDAP_BIND_DN'], password=app.config['LDAP_BIND_PASSWORD'], authentication=SIMPLE, auto_bind=True, read_only=True)
422
+ try:
423
+ conn.search(search_base=app.config['LDAP_BASE_DN'], search_filter=user_filter, search_scope=SUBTREE)
424
+ if len(conn.entries) == 0:
425
+ print(f"User {username} not found in LDAP.")
426
+ return False
427
+ user_dn = conn.entries[0].entry_dn
428
+ finally:
429
+ conn.unbind()
430
+
431
+ # Bind with the user DN and password to verify credentials
432
+ conn = Connection(server, user=user_dn, password=password, authentication=SIMPLE, auto_bind=True, read_only=True)
433
+ try:
434
+ if not app.config['LDAP_GROUPS'] and conn.result["result"] == 0:
435
+ return True
436
+ group_filter = "".join([f'({group})' for group in app.config['LDAP_GROUPS'].split(",")])
437
+ group_filter = f"(&{group_filter}(|(member={user_dn})(uniqueMember={user_dn})))"
438
+ conn.search(search_base=app.config['LDAP_BASE_DN'], search_filter=group_filter, search_scope=SUBTREE)
439
+ result = len(conn.entries) > 0
440
+ if not result:
441
+ print(f"User {username} is not a member of groups {app.config['LDAP_GROUPS']}.")
442
+ return result
443
+ finally:
444
+ conn.unbind()
445
+ except Exception as e:
446
+ print(f"LDAP authentication failed: {e}")
447
+ return False
448
+
449
+ @app.route('/run_command', methods=['POST'])
450
+ def run_command_endpoint():
451
+ data = request.json
452
+ command = data.get('command')
453
+ params = data.get('params', [])
454
+
455
+ if not command:
456
+ return jsonify({'error': 'command is required'}), 400
457
+
458
+ # Ensure the command is an executable in the current directory
459
+ command_path = os.path.join(".", os.path.basename(command))
460
+ if not os.path.isfile(command_path) or not os.access(command_path, os.X_OK):
461
+ return jsonify({'error': 'command must be an executable in the current directory'}), 400
462
+
463
+ # Split params using shell-like syntax
464
+ try:
465
+ params = shlex.split(' '.join(params))
466
+ except ValueError as e:
467
+ return jsonify({'error': str(e)}), 400
468
+
469
+ # Generate a unique command_id
470
+ command_id = str(uuid.uuid4())
471
+
472
+ # Get the user from the session
473
+ user = session.get('username', '-')
474
+
475
+ # Set the initial status to running and save command details
476
+ update_command_status(command_id, 'running', command, params, user=user)
477
+
478
+ # Run the command in a separate thread
479
+ thread = threading.Thread(target=run_command, args=(command_path, params, command_id, user))
480
+ thread.start()
481
+
482
+ return jsonify({'message': 'Command is running', 'command_id': command_id})
483
+
484
+ @app.route('/stop_command/<command_id>', methods=['POST'])
485
+ def stop_command(command_id):
486
+ status = read_command_status(command_id)
487
+ if not status or 'pid' not in status:
488
+ return jsonify({'error': 'Invalid command_id or command not running'}), 400
489
+
490
+ pid = status['pid']
491
+ end_time = datetime.now().isoformat()
492
+ try:
493
+ os.kill(pid, 15) # Send SIGTERM
494
+ #update_command_status(command_id, 'aborted', end_time=end_time, exit_code=-15)
495
+ return jsonify({'message': 'Command aborted'})
496
+ except Exception as e:
497
+ status_data = read_command_status(command_id) or {}
498
+ status_data['status'] = 'failed'
499
+ status_data['end_time'] = end_time
500
+ status_data['exit_code'] = 1
501
+ with open(get_status_file_path(command_id), 'w') as f:
502
+ json.dump(status_data, f)
503
+ with open(get_output_file_path(command_id), 'a') as output_file:
504
+ output_file.write(str(e))
505
+ return jsonify({'error': 'Failed to terminate command'}), 500
506
+
507
+ @app.route('/command_status/<command_id>', methods=['GET'])
508
+ def get_command_status(command_id):
509
+ status = read_command_status(command_id)
510
+ if not status:
511
+ return jsonify({'error': 'Invalid command_id'}), 404
512
+
513
+ # output_file_path = get_output_file_path(command_id)
514
+ # if os.path.exists(output_file_path):
515
+ # with open(output_file_path, 'r') as output_file:
516
+ # output = output_file.read()
517
+ # status['output'] = output
518
+
519
+ return jsonify(status)
520
+
521
+ @app.route('/')
522
+ def index():
523
+ return render_template('index.html', title=args.title)
524
+
525
+ @app.route('/commands', methods=['GET'])
526
+ def list_commands():
527
+ commands = []
528
+ for filename in os.listdir(COMMAND_STATUS_DIR):
529
+ if filename.endswith('.json'):
530
+ command_id = filename[:-5]
531
+ status = read_command_status(command_id)
532
+ if status:
533
+ try:
534
+ params = shlex.join(status['params'])
535
+ except AttributeError:
536
+ params = " ".join([shlex.quote(p) if " " in p else p for p in status['params']])
537
+ command = status.get('command', '-') + ' ' + params
538
+ last_line = status.get('last_output_line')
539
+ if last_line is None:
540
+ output_file_path = get_output_file_path(command_id)
541
+ if os.path.exists(output_file_path):
542
+ last_line = get_last_non_empty_line_of_file(output_file_path)
543
+ commands.append({
544
+ 'command_id': command_id,
545
+ 'status': status['status'],
546
+ 'start_time': status.get('start_time', 'N/A'),
547
+ 'end_time': status.get('end_time', 'N/A'),
548
+ 'command': command,
549
+ 'exit_code': status.get('exit_code', 'N/A'),
550
+ 'last_output_line': last_line,
551
+ })
552
+ # Sort commands by start_time in descending order
553
+ commands.sort(key=lambda x: x['start_time'], reverse=True)
554
+ return jsonify(commands)
555
+
556
+ @app.route('/command_output/<command_id>', methods=['GET'])
557
+ def get_command_output(command_id):
558
+ offset = int(request.args.get('offset', 0))
559
+ output_file_path = get_output_file_path(command_id)
560
+ if os.path.exists(output_file_path):
561
+ with open(output_file_path, 'rb') as output_file:
562
+ output_file.seek(offset)
563
+ output = output_file.read().decode('utf-8', errors='replace')
564
+ new_offset = output_file.tell()
565
+ status_data = read_command_status(command_id) or {}
566
+ response = {
567
+ 'output': output,
568
+ 'status': status_data.get("status"),
569
+ 'links': {
570
+ 'next': f'{request.url_root}command_output/{command_id}?offset={new_offset}'
571
+ }
572
+ }
573
+ if request.headers.get('Accept') == 'text/plain':
574
+ return f"{output}\nstatus: {status_data.get('status')}", 200, {'Content-Type': 'text/plain'}
575
+ return jsonify(response)
576
+ return jsonify({'error': 'Invalid command_id'}), 404
577
+
578
+ @app.route('/executables', methods=['GET'])
579
+ def list_executables():
580
+ executables = [f for f in os.listdir('.') if os.path.isfile(f) and os.access(f, os.X_OK)]
581
+ return jsonify(executables)
582
+
583
+ def main():
584
+ basef = f"{CONFDIR}/pywebexec_{args.listen}:{args.port}"
585
+ if not os.path.exists(CONFDIR):
586
+ os.mkdir(CONFDIR, mode=0o700)
587
+ if args.action == "start":
588
+ return start_gunicorn(daemon=True, baselog=basef)
589
+ if args.action:
590
+ return daemon_d(args.action, pidfilepath=basef)
591
+ return start_gunicorn()
592
+
593
+ if __name__ == '__main__':
594
+ main()
595
+ # app.run(host='0.0.0.0', port=5000)