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 +306 -107
- pywebexec/static/css/style.css +112 -0
- pywebexec/static/images/favicon.svg +1 -0
- pywebexec/static/js/script.js +197 -0
- pywebexec/templates/index.html +11 -300
- pywebexec/version.py +2 -2
- {pywebexec-0.1.1.dist-info → pywebexec-1.1.0.dist-info}/METADATA +49 -14
- pywebexec-1.1.0.dist-info/RECORD +20 -0
- pywebexec-0.1.1.dist-info/RECORD +0 -17
- {pywebexec-0.1.1.dist-info → pywebexec-1.1.0.dist-info}/LICENSE +0 -0
- {pywebexec-0.1.1.dist-info → pywebexec-1.1.0.dist-info}/WHEEL +0 -0
- {pywebexec-0.1.1.dist-info → pywebexec-1.1.0.dist-info}/entry_points.txt +0 -0
- {pywebexec-0.1.1.dist-info → pywebexec-1.1.0.dist-info}/top_level.txt +0 -0
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
|
-
|
|
20
|
-
|
|
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(
|
|
27
|
-
os.makedirs(
|
|
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
|
|
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="
|
|
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(
|
|
162
|
-
return os.path.join(
|
|
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(
|
|
165
|
-
return os.path.join(
|
|
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
|
|
168
|
-
status_file_path = get_status_file_path(
|
|
169
|
-
status_data =
|
|
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
|
|
172
|
-
status_data['
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
|
357
|
+
def run_command(command, params, command_id):
|
|
197
358
|
start_time = datetime.now().isoformat()
|
|
198
|
-
|
|
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(
|
|
361
|
+
output_file_path = get_output_file_path(command_id)
|
|
201
362
|
with open(output_file_path, 'w') as output_file:
|
|
202
|
-
# Run the
|
|
203
|
-
process = subprocess.Popen([
|
|
204
|
-
|
|
205
|
-
processes[
|
|
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(
|
|
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
|
-
|
|
373
|
+
update_command_status(command_id, 'success', end_time=end_time, exit_code=process.returncode)
|
|
213
374
|
elif process.returncode == -15:
|
|
214
|
-
|
|
375
|
+
update_command_status(command_id, 'aborted', end_time=end_time, exit_code=process.returncode)
|
|
215
376
|
else:
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
with open(get_output_file_path(
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
@
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
440
|
+
command = data.get('command')
|
|
233
441
|
params = data.get('params', [])
|
|
234
442
|
|
|
235
|
-
if not
|
|
236
|
-
return jsonify({'error': '
|
|
443
|
+
if not command:
|
|
444
|
+
return jsonify({'error': 'command is required'}), 400
|
|
237
445
|
|
|
238
|
-
# Ensure the
|
|
239
|
-
|
|
240
|
-
if not os.path.isfile(
|
|
241
|
-
return jsonify({'error': '
|
|
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
|
|
250
|
-
|
|
457
|
+
# Generate a unique command_id
|
|
458
|
+
command_id = str(uuid.uuid4())
|
|
251
459
|
|
|
252
|
-
# Set the initial status to running and save
|
|
253
|
-
|
|
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
|
|
256
|
-
thread = threading.Thread(target=
|
|
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': '
|
|
467
|
+
return jsonify({'message': 'Command is running', 'command_id': command_id})
|
|
260
468
|
|
|
261
|
-
@app.route('/
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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
|
-
|
|
273
|
-
return jsonify({'message': '
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
490
|
+
return jsonify({'error': 'Failed to terminate command'}), 500
|
|
284
491
|
|
|
285
|
-
@app.route('/
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
496
|
+
return jsonify({'error': 'Invalid command_id'}), 404
|
|
291
497
|
|
|
292
|
-
output_file_path = get_output_file_path(
|
|
293
|
-
if os.path.exists(output_file_path):
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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('/
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
status =
|
|
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['
|
|
319
|
-
|
|
320
|
-
'
|
|
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
|
|
328
|
-
|
|
329
|
-
return jsonify(
|
|
330
|
-
|
|
331
|
-
@app.route('/
|
|
332
|
-
|
|
333
|
-
|
|
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 =
|
|
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
|
|
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)
|