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