pywebexec 1.2.4__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
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)