pywebexec 1.1.2__py3-none-any.whl → 1.4.12__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 +255 -94
- pywebexec/static/css/Consolas NF.ttf +0 -0
- pywebexec/static/css/style.css +187 -18
- pywebexec/static/css/xterm.css +209 -0
- pywebexec/static/images/down-arrow.svg +1 -0
- pywebexec/static/images/favicon.svg +8 -1
- pywebexec/static/images/popup.svg +1 -0
- pywebexec/static/images/running.gif +0 -0
- pywebexec/static/js/commands.js +223 -0
- pywebexec/static/js/popup.js +141 -0
- pywebexec/static/js/script.js +248 -110
- pywebexec/static/js/xterm/LICENSE +21 -0
- pywebexec/static/js/xterm/ansi_up.min.js +7 -0
- pywebexec/static/js/xterm/xterm-addon-fit.js +1 -0
- pywebexec/static/js/xterm/xterm.js +1 -0
- pywebexec/templates/index.html +23 -7
- pywebexec/templates/popup.html +24 -0
- pywebexec/version.py +2 -2
- {pywebexec-1.1.2.dist-info → pywebexec-1.4.12.dist-info}/METADATA +49 -19
- pywebexec-1.4.12.dist-info/RECORD +31 -0
- pywebexec/static/images/running.svg +0 -1
- pywebexec-1.1.2.dist-info/RECORD +0 -20
- {pywebexec-1.1.2.dist-info → pywebexec-1.4.12.dist-info}/LICENSE +0 -0
- {pywebexec-1.1.2.dist-info → pywebexec-1.4.12.dist-info}/WHEEL +0 -0
- {pywebexec-1.1.2.dist-info → pywebexec-1.4.12.dist-info}/entry_points.txt +0 -0
- {pywebexec-1.1.2.dist-info → pywebexec-1.4.12.dist-info}/top_level.txt +0 -0
pywebexec/pywebexec.py
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
import sys
|
2
2
|
from flask import Flask, request, jsonify, render_template, session, redirect, url_for
|
3
3
|
from flask_httpauth import HTTPBasicAuth
|
4
|
-
import subprocess
|
5
4
|
import threading
|
6
5
|
import os
|
7
6
|
import json
|
@@ -10,18 +9,25 @@ import argparse
|
|
10
9
|
import random
|
11
10
|
import string
|
12
11
|
from datetime import datetime, timezone, timedelta
|
12
|
+
import time
|
13
13
|
import shlex
|
14
14
|
from gunicorn.app.base import Application
|
15
15
|
import ipaddress
|
16
|
-
from socket import gethostname, gethostbyname_ex
|
16
|
+
from socket import gethostname, gethostbyname_ex, gethostbyaddr, inet_aton, inet_ntoa
|
17
17
|
import ssl
|
18
|
+
import re
|
19
|
+
import pwd
|
20
|
+
from secrets import token_urlsafe
|
21
|
+
import pexpect
|
22
|
+
import signal
|
23
|
+
import fcntl
|
24
|
+
import termios
|
25
|
+
import struct
|
26
|
+
import subprocess
|
27
|
+
|
18
28
|
|
19
29
|
if os.environ.get('PYWEBEXEC_LDAP_SERVER'):
|
20
|
-
|
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)
|
30
|
+
from ldap3 import Server, Connection, ALL, SIMPLE, SUBTREE, Tls
|
25
31
|
|
26
32
|
app = Flask(__name__)
|
27
33
|
app.secret_key = os.urandom(24) # Secret key for session management
|
@@ -36,14 +42,13 @@ app.config['LDAP_BIND_DN'] = os.environ.get('PYWEBEXEC_LDAP_BIND_DN')
|
|
36
42
|
app.config['LDAP_BIND_PASSWORD'] = os.environ.get('PYWEBEXEC_LDAP_BIND_PASSWORD')
|
37
43
|
|
38
44
|
# Directory to store the command status and output
|
45
|
+
CWD = os.getcwd()
|
39
46
|
COMMAND_STATUS_DIR = '.web_status'
|
40
47
|
CONFDIR = os.path.expanduser("~/")
|
41
48
|
if os.path.isdir(f"{CONFDIR}/.config"):
|
42
49
|
CONFDIR += '/.config'
|
43
50
|
CONFDIR += "/.pywebexec"
|
44
51
|
|
45
|
-
if not os.path.exists(COMMAND_STATUS_DIR):
|
46
|
-
os.makedirs(COMMAND_STATUS_DIR)
|
47
52
|
|
48
53
|
# In-memory cache for command statuses
|
49
54
|
command_status_cache = {}
|
@@ -54,11 +59,38 @@ def generate_random_password(length=12):
|
|
54
59
|
|
55
60
|
|
56
61
|
def resolve_hostname(host):
|
57
|
-
"""try get fqdn from DNS"""
|
62
|
+
"""try get fqdn from DNS/hosts"""
|
58
63
|
try:
|
59
|
-
|
64
|
+
hostinfo = gethostbyname_ex(host)
|
65
|
+
return (hostinfo[0].rstrip('.'), hostinfo[2][0])
|
60
66
|
except OSError:
|
61
|
-
return host
|
67
|
+
return (host, host)
|
68
|
+
|
69
|
+
|
70
|
+
def resolve_ip(ip):
|
71
|
+
"""try resolve hostname by reverse dns query on ip addr"""
|
72
|
+
ip = inet_ntoa(inet_aton(ip))
|
73
|
+
try:
|
74
|
+
ipinfo = gethostbyaddr(ip)
|
75
|
+
return (ipinfo[0].rstrip('.'), ipinfo[2][0])
|
76
|
+
except OSError:
|
77
|
+
return (ip, ip)
|
78
|
+
|
79
|
+
|
80
|
+
def is_ip(host):
|
81
|
+
"""determine if host is valid ip"""
|
82
|
+
try:
|
83
|
+
inet_aton(host)
|
84
|
+
return True
|
85
|
+
except OSError:
|
86
|
+
return False
|
87
|
+
|
88
|
+
|
89
|
+
def resolve(host_or_ip):
|
90
|
+
"""resolve hostname from ip / hostname"""
|
91
|
+
if is_ip(host_or_ip):
|
92
|
+
return resolve_ip(host_or_ip)
|
93
|
+
return resolve_hostname(host_or_ip)
|
62
94
|
|
63
95
|
|
64
96
|
def generate_selfsigned_cert(hostname, ip_addresses=None, key=None):
|
@@ -124,7 +156,7 @@ def generate_selfsigned_cert(hostname, ip_addresses=None, key=None):
|
|
124
156
|
|
125
157
|
|
126
158
|
|
127
|
-
class
|
159
|
+
class PyWebExec(Application):
|
128
160
|
|
129
161
|
def __init__(self, app, options=None):
|
130
162
|
self.options = options or {}
|
@@ -143,10 +175,17 @@ class StandaloneApplication(Application):
|
|
143
175
|
return self.application
|
144
176
|
|
145
177
|
|
178
|
+
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])')
|
179
|
+
|
180
|
+
def strip_ansi_control_chars(text):
|
181
|
+
"""Remove ANSI and control characters from the text."""
|
182
|
+
return ANSI_ESCAPE.sub(b'', text)
|
183
|
+
|
184
|
+
|
146
185
|
def decode_line(line: bytes) -> str:
|
147
186
|
"""try decode line exception on binary"""
|
148
187
|
try:
|
149
|
-
return line.decode()
|
188
|
+
return strip_ansi_control_chars(line).decode().strip(" ")
|
150
189
|
except UnicodeDecodeError:
|
151
190
|
return ""
|
152
191
|
|
@@ -156,8 +195,11 @@ def last_line(fd, maxline=1000):
|
|
156
195
|
line = "\n"
|
157
196
|
fd.seek(0, os.SEEK_END)
|
158
197
|
size = 0
|
159
|
-
|
198
|
+
last_pos = 0
|
199
|
+
while line in ["", "\n", "\r"] and size < maxline:
|
160
200
|
try: # catch if file empty / only empty lines
|
201
|
+
if last_pos:
|
202
|
+
fd.seek(last_pos-2, os.SEEK_SET)
|
161
203
|
while fd.read(1) not in [b"\n", b"\r"]:
|
162
204
|
fd.seek(-2, os.SEEK_CUR)
|
163
205
|
size += 1
|
@@ -165,8 +207,8 @@ def last_line(fd, maxline=1000):
|
|
165
207
|
fd.seek(0)
|
166
208
|
line = decode_line(fd.readline())
|
167
209
|
break
|
210
|
+
last_pos = fd.tell()
|
168
211
|
line = decode_line(fd.readline())
|
169
|
-
fd.seek(-4, os.SEEK_CUR)
|
170
212
|
return line.strip()
|
171
213
|
|
172
214
|
|
@@ -176,31 +218,35 @@ def get_last_non_empty_line_of_file(file_path):
|
|
176
218
|
return last_line(f)
|
177
219
|
|
178
220
|
|
179
|
-
def start_gunicorn(
|
180
|
-
|
221
|
+
def start_gunicorn(daemonized=False, baselog=None):
|
222
|
+
pidfile = f"{baselog}.pid"
|
223
|
+
if daemonized:
|
224
|
+
if daemon_d('status', pidfilepath=baselog, silent=True):
|
225
|
+
print(f"Error: pywebexec already running on {args.listen}:{args.port}", file=sys.stderr)
|
226
|
+
sys.exit(1)
|
227
|
+
|
228
|
+
if sys.stdout.isatty():
|
229
|
+
errorlog = "-"
|
230
|
+
accesslog = None #"-"
|
231
|
+
else:
|
181
232
|
errorlog = f"{baselog}.log"
|
182
233
|
accesslog = None # f"{baselog}.access.log"
|
183
|
-
|
184
|
-
else:
|
185
|
-
errorlog = "-"
|
186
|
-
accesslog = "-"
|
187
|
-
pidfile = None
|
234
|
+
|
188
235
|
options = {
|
189
236
|
'bind': '%s:%s' % (args.listen, args.port),
|
190
237
|
'workers': 4,
|
191
238
|
'timeout': 600,
|
192
239
|
'certfile': args.cert,
|
193
240
|
'keyfile': args.key,
|
194
|
-
'daemon':
|
241
|
+
'daemon': daemonized,
|
195
242
|
'errorlog': errorlog,
|
196
243
|
'accesslog': accesslog,
|
197
244
|
'pidfile': pidfile,
|
198
245
|
}
|
199
|
-
|
246
|
+
PyWebExec(app, options=options).run()
|
200
247
|
|
201
|
-
def daemon_d(action, pidfilepath, hostname=None, args=None):
|
248
|
+
def daemon_d(action, pidfilepath, silent=False, hostname=None, args=None):
|
202
249
|
"""start/stop daemon"""
|
203
|
-
import signal
|
204
250
|
import daemon, daemon.pidfile
|
205
251
|
|
206
252
|
pidfile = daemon.pidfile.TimeoutPIDLockFile(pidfilepath+".pid", acquire_timeout=30)
|
@@ -218,10 +264,14 @@ def daemon_d(action, pidfilepath, hostname=None, args=None):
|
|
218
264
|
if status:
|
219
265
|
print(f"pywebexec running pid {pidfile.read_pid()}")
|
220
266
|
return True
|
221
|
-
|
267
|
+
if not silent:
|
268
|
+
print("pywebexec not running")
|
222
269
|
return False
|
223
270
|
elif action == "start":
|
224
|
-
|
271
|
+
status = pidfile.is_locked()
|
272
|
+
if status:
|
273
|
+
print(f"pywebexc already running pid {pidfile.read_pid()}", file=sys.stderr)
|
274
|
+
sys.exit(1)
|
225
275
|
log = open(pidfilepath + ".log", "ab+")
|
226
276
|
daemon_context = daemon.DaemonContext(
|
227
277
|
stderr=log,
|
@@ -235,8 +285,22 @@ def daemon_d(action, pidfilepath, hostname=None, args=None):
|
|
235
285
|
except Exception as e:
|
236
286
|
print(e)
|
237
287
|
|
288
|
+
def start_term():
|
289
|
+
os.environ["PYWEBEXEC"] = " (shared)"
|
290
|
+
os.chdir(CWD)
|
291
|
+
command_id = str(uuid.uuid4())
|
292
|
+
start_time = datetime.now().isoformat()
|
293
|
+
user = pwd.getpwuid(os.getuid())[0]
|
294
|
+
update_command_status(command_id, 'running', command="term", params=[user,os.ttyname(sys.stdout.fileno())], start_time=start_time, user=user)
|
295
|
+
output_file_path = get_output_file_path(command_id)
|
296
|
+
res = script(output_file_path)
|
297
|
+
end_time = datetime.now().isoformat()
|
298
|
+
update_command_status(command_id, status="success", end_time=end_time, exit_code=res)
|
299
|
+
return res
|
300
|
+
|
238
301
|
def parseargs():
|
239
|
-
global app, args
|
302
|
+
global app, args, COMMAND_STATUS_DIR
|
303
|
+
|
240
304
|
parser = argparse.ArgumentParser(description='Run the command execution server.')
|
241
305
|
parser.add_argument('-u', '--user', help='Username for basic auth')
|
242
306
|
parser.add_argument('-P', '--password', help='Password for basic auth')
|
@@ -253,13 +317,15 @@ def parseargs():
|
|
253
317
|
"-t",
|
254
318
|
"--title",
|
255
319
|
type=str,
|
256
|
-
default="
|
320
|
+
default="PyWebExec",
|
257
321
|
help="Web html title",
|
258
322
|
)
|
259
323
|
parser.add_argument("-c", "--cert", type=str, help="Path to https certificate")
|
260
324
|
parser.add_argument("-k", "--key", type=str, help="Path to https certificate key")
|
261
325
|
parser.add_argument("-g", "--gencert", action="store_true", help="https server self signed cert")
|
262
|
-
parser.add_argument("
|
326
|
+
parser.add_argument("-T", "--tokenurl", action="store_true", help="generate safe url to access")
|
327
|
+
parser.add_argument("action", nargs="?", help="daemon action start/stop/restart/status/shareterm/term",
|
328
|
+
choices=["start","stop","restart","status","shareterm", "term"])
|
263
329
|
|
264
330
|
args = parser.parse_args()
|
265
331
|
if os.path.isdir(args.dir):
|
@@ -271,9 +337,23 @@ def parseargs():
|
|
271
337
|
else:
|
272
338
|
print(f"Error: {args.dir} not found", file=sys.stderr)
|
273
339
|
sys.exit(1)
|
340
|
+
if not os.path.exists(COMMAND_STATUS_DIR):
|
341
|
+
os.makedirs(COMMAND_STATUS_DIR)
|
342
|
+
if not os.path.exists(CONFDIR):
|
343
|
+
os.mkdir(CONFDIR, mode=0o700)
|
344
|
+
if args.action == "term":
|
345
|
+
COMMAND_STATUS_DIR = f"{os.getcwd()}/{COMMAND_STATUS_DIR}"
|
346
|
+
sys.exit(start_term())
|
347
|
+
(hostname, ip) = resolve(gethostname()) if args.listen == '0.0.0.0' else resolve(args.listen)
|
348
|
+
url_params = ""
|
349
|
+
|
350
|
+
if args.tokenurl:
|
351
|
+
token = os.environ.get("PYWEBEXEC_TOKEN", token_urlsafe())
|
352
|
+
os.environ["PYWEBEXEC_TOKEN"] = token
|
353
|
+
app.config["TOKEN_URL"] = token
|
354
|
+
url_params = f"?token={token}"
|
274
355
|
|
275
356
|
if args.gencert:
|
276
|
-
hostname = resolve_hostname(gethostname())
|
277
357
|
args.cert = args.cert or f"{CONFDIR}/pywebexec.crt"
|
278
358
|
args.key = args.key or f"{CONFDIR}/pywebexec.key"
|
279
359
|
if not os.path.exists(args.cert):
|
@@ -294,9 +374,13 @@ def parseargs():
|
|
294
374
|
app.config['USER'] = None
|
295
375
|
app.config['PASSWORD'] = None
|
296
376
|
|
297
|
-
|
377
|
+
if args.action != 'stop':
|
378
|
+
print("Starting server:")
|
379
|
+
protocol = 'https' if args.cert else 'http'
|
380
|
+
print(f"{protocol}://{hostname}:{args.port}{url_params}")
|
381
|
+
print(f"{protocol}://{ip}:{args.port}{url_params}")
|
298
382
|
|
299
|
-
|
383
|
+
return args
|
300
384
|
|
301
385
|
def get_status_file_path(command_id):
|
302
386
|
return os.path.join(COMMAND_STATUS_DIR, f'{command_id}.json')
|
@@ -304,7 +388,7 @@ def get_status_file_path(command_id):
|
|
304
388
|
def get_output_file_path(command_id):
|
305
389
|
return os.path.join(COMMAND_STATUS_DIR, f'{command_id}_output.txt')
|
306
390
|
|
307
|
-
def update_command_status(command_id, status, command=None, params=None, start_time=None, end_time=None, exit_code=None, pid=None):
|
391
|
+
def update_command_status(command_id, status, command=None, params=None, start_time=None, end_time=None, exit_code=None, pid=None, user=None):
|
308
392
|
status_file_path = get_status_file_path(command_id)
|
309
393
|
status_data = read_command_status(command_id) or {}
|
310
394
|
status_data['status'] = status
|
@@ -320,6 +404,8 @@ def update_command_status(command_id, status, command=None, params=None, start_t
|
|
320
404
|
status_data['exit_code'] = exit_code
|
321
405
|
if pid is not None:
|
322
406
|
status_data['pid'] = pid
|
407
|
+
if user is not None:
|
408
|
+
status_data['user'] = user
|
323
409
|
if status != 'running':
|
324
410
|
output_file_path = get_output_file_path(command_id)
|
325
411
|
if os.path.exists(output_file_path):
|
@@ -342,7 +428,10 @@ def read_command_status(command_id):
|
|
342
428
|
if not os.path.exists(status_file_path):
|
343
429
|
return None
|
344
430
|
with open(status_file_path, 'r') as f:
|
345
|
-
|
431
|
+
try:
|
432
|
+
status_data = json.load(f)
|
433
|
+
except json.JSONDecodeError:
|
434
|
+
return None
|
346
435
|
|
347
436
|
# Cache the status if it is not "running"
|
348
437
|
if status_data['status'] != 'running':
|
@@ -350,38 +439,93 @@ def read_command_status(command_id):
|
|
350
439
|
|
351
440
|
return status_data
|
352
441
|
|
353
|
-
|
354
|
-
|
442
|
+
def sigwinch_passthrough(sig, data):
|
443
|
+
s = struct.pack("HHHH", 0, 0, 0, 0)
|
444
|
+
a = struct.unpack('hhhh', fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, s))
|
445
|
+
global p
|
446
|
+
p.setwinsize(a[0], a[1])
|
447
|
+
|
448
|
+
|
449
|
+
def script(output_file):
|
450
|
+
global p
|
451
|
+
shell = os.environ.get('SHELL', 'sh')
|
452
|
+
with open(output_file, 'wb') as fd:
|
453
|
+
p = pexpect.spawn(shell, echo=True)
|
454
|
+
p.logfile_read = fd
|
455
|
+
# Set the window size
|
456
|
+
sigwinch_passthrough(None, None)
|
457
|
+
signal.signal(signal.SIGWINCH, sigwinch_passthrough)
|
458
|
+
p.interact()
|
459
|
+
|
355
460
|
|
356
|
-
def run_command(command, params, command_id):
|
461
|
+
def run_command(command, params, command_id, user):
|
357
462
|
start_time = datetime.now().isoformat()
|
358
|
-
update_command_status(command_id, 'running', command=command, params=params, start_time=start_time)
|
463
|
+
update_command_status(command_id, 'running', command=command, params=params, start_time=start_time, user=user)
|
464
|
+
output_file_path = get_output_file_path(command_id)
|
359
465
|
try:
|
360
|
-
output_file_path
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
466
|
+
with open(output_file_path, 'wb') as fd:
|
467
|
+
p = pexpect.spawn(command, params, ignore_sighup=True, timeout=None)
|
468
|
+
update_command_status(command_id, 'running', pid=p.pid, user=user)
|
469
|
+
p.setwinsize(24, 125)
|
470
|
+
p.logfile = fd
|
471
|
+
p.expect(pexpect.EOF)
|
472
|
+
fd.flush()
|
473
|
+
status = p.wait()
|
474
|
+
end_time = datetime.now().isoformat()
|
475
|
+
# Update the status based on the result
|
476
|
+
if status is None:
|
477
|
+
exit_code = -15
|
478
|
+
update_command_status(command_id, 'aborted', end_time=end_time, exit_code=exit_code, user=user)
|
479
|
+
else:
|
480
|
+
exit_code = status
|
481
|
+
if exit_code == 0:
|
482
|
+
update_command_status(command_id, 'success', end_time=end_time, exit_code=exit_code, user=user)
|
483
|
+
else:
|
484
|
+
update_command_status(command_id, 'failed', end_time=end_time, exit_code=exit_code, user=user)
|
377
485
|
except Exception as e:
|
378
486
|
end_time = datetime.now().isoformat()
|
379
|
-
update_command_status(command_id, 'failed', end_time=end_time, exit_code=1)
|
487
|
+
update_command_status(command_id, 'failed', end_time=end_time, exit_code=1, user=user)
|
488
|
+
with open(get_output_file_path(command_id), 'a') as output_file:
|
489
|
+
output_file.write(str(e))
|
490
|
+
|
491
|
+
@app.route('/stop_command/<command_id>', methods=['POST'])
|
492
|
+
def stop_command(command_id):
|
493
|
+
status = read_command_status(command_id)
|
494
|
+
if not status or 'pid' not in status:
|
495
|
+
return jsonify({'error': 'Invalid command_id or command not running'}), 400
|
496
|
+
|
497
|
+
pid = status['pid']
|
498
|
+
end_time = datetime.now().isoformat()
|
499
|
+
try:
|
500
|
+
#update_command_status(command_id, 'aborted', end_time=end_time, exit_code=-15)
|
501
|
+
os.killpg(os.getpgid(pid), 15) # Send SIGTERM to the process group
|
502
|
+
return jsonify({'message': 'Command aborted'})
|
503
|
+
except Exception as e:
|
504
|
+
status_data = read_command_status(command_id) or {}
|
505
|
+
status_data['status'] = 'failed'
|
506
|
+
status_data['end_time'] = end_time
|
507
|
+
status_data['exit_code'] = 1
|
508
|
+
with open(get_status_file_path(command_id), 'w') as f:
|
509
|
+
json.dump(status_data, f)
|
380
510
|
with open(get_output_file_path(command_id), 'a') as output_file:
|
381
511
|
output_file.write(str(e))
|
512
|
+
return jsonify({'error': 'Failed to terminate command'}), 500
|
513
|
+
|
514
|
+
parseargs()
|
515
|
+
|
382
516
|
|
383
517
|
@app.before_request
|
384
518
|
def check_authentication():
|
519
|
+
# Check for token in URL if TOKEN_URL is set
|
520
|
+
token = app.config.get('TOKEN_URL')
|
521
|
+
if token and request.endpoint not in ['login', 'static']:
|
522
|
+
if request.args.get('token') == token:
|
523
|
+
return
|
524
|
+
return jsonify({'error': 'Forbidden'}), 403
|
525
|
+
|
526
|
+
if not app.config['USER'] and not app.config['LDAP_SERVER']:
|
527
|
+
return
|
528
|
+
|
385
529
|
if 'username' not in session and request.endpoint not in ['login', 'static']:
|
386
530
|
return auth.login_required(lambda: None)()
|
387
531
|
|
@@ -456,38 +600,18 @@ def run_command_endpoint():
|
|
456
600
|
# Generate a unique command_id
|
457
601
|
command_id = str(uuid.uuid4())
|
458
602
|
|
603
|
+
# Get the user from the session
|
604
|
+
user = session.get('username', '-')
|
605
|
+
|
459
606
|
# Set the initial status to running and save command details
|
460
|
-
update_command_status(command_id, 'running', command, params)
|
607
|
+
update_command_status(command_id, 'running', command, params, user=user)
|
461
608
|
|
462
609
|
# Run the command in a separate thread
|
463
|
-
thread = threading.Thread(target=run_command, args=(command_path, params, command_id))
|
610
|
+
thread = threading.Thread(target=run_command, args=(command_path, params, command_id, user))
|
464
611
|
thread.start()
|
465
612
|
|
466
613
|
return jsonify({'message': 'Command is running', 'command_id': command_id})
|
467
614
|
|
468
|
-
@app.route('/stop_command/<command_id>', methods=['POST'])
|
469
|
-
def stop_command(command_id):
|
470
|
-
status = read_command_status(command_id)
|
471
|
-
if not status or 'pid' not in status:
|
472
|
-
return jsonify({'error': 'Invalid command_id or command not running'}), 400
|
473
|
-
|
474
|
-
pid = status['pid']
|
475
|
-
end_time = datetime.now().isoformat()
|
476
|
-
try:
|
477
|
-
os.kill(pid, 15) # Send SIGTERM
|
478
|
-
update_command_status(command_id, 'aborted', end_time=end_time, exit_code=-15)
|
479
|
-
return jsonify({'message': 'Command aborted'})
|
480
|
-
except Exception as e:
|
481
|
-
status_data = read_command_status(command_id) or {}
|
482
|
-
status_data['status'] = 'failed'
|
483
|
-
status_data['end_time'] = end_time
|
484
|
-
status_data['exit_code'] = 1
|
485
|
-
with open(get_status_file_path(command_id), 'w') as f:
|
486
|
-
json.dump(status_data, f)
|
487
|
-
with open(get_output_file_path(command_id), 'a') as output_file:
|
488
|
-
output_file.write(str(e))
|
489
|
-
return jsonify({'error': 'Failed to terminate command'}), 500
|
490
|
-
|
491
615
|
@app.route('/command_status/<command_id>', methods=['GET'])
|
492
616
|
def get_command_status(command_id):
|
493
617
|
status = read_command_status(command_id)
|
@@ -515,10 +639,15 @@ def list_commands():
|
|
515
639
|
status = read_command_status(command_id)
|
516
640
|
if status:
|
517
641
|
try:
|
518
|
-
params = shlex.join(status
|
642
|
+
params = shlex.join(status.get('params', []))
|
519
643
|
except AttributeError:
|
520
644
|
params = " ".join([shlex.quote(p) if " " in p else p for p in status['params']])
|
521
|
-
command = status
|
645
|
+
command = status.get('command', '-') + ' ' + params
|
646
|
+
last_line = status.get('last_output_line')
|
647
|
+
if last_line is None:
|
648
|
+
output_file_path = get_output_file_path(command_id)
|
649
|
+
if os.path.exists(output_file_path):
|
650
|
+
last_line = get_last_non_empty_line_of_file(output_file_path)
|
522
651
|
commands.append({
|
523
652
|
'command_id': command_id,
|
524
653
|
'status': status['status'],
|
@@ -526,7 +655,7 @@ def list_commands():
|
|
526
655
|
'end_time': status.get('end_time', 'N/A'),
|
527
656
|
'command': command,
|
528
657
|
'exit_code': status.get('exit_code', 'N/A'),
|
529
|
-
'last_output_line':
|
658
|
+
'last_output_line': last_line,
|
530
659
|
})
|
531
660
|
# Sort commands by start_time in descending order
|
532
661
|
commands.sort(key=lambda x: x['start_time'], reverse=True)
|
@@ -534,28 +663,60 @@ def list_commands():
|
|
534
663
|
|
535
664
|
@app.route('/command_output/<command_id>', methods=['GET'])
|
536
665
|
def get_command_output(command_id):
|
666
|
+
offset = int(request.args.get('offset', 0))
|
667
|
+
maxsize = int(request.args.get('maxsize', 10485760))
|
537
668
|
output_file_path = get_output_file_path(command_id)
|
538
669
|
if os.path.exists(output_file_path):
|
539
|
-
with open(output_file_path, '
|
540
|
-
|
670
|
+
with open(output_file_path, 'rb') as output_file:
|
671
|
+
output_file.seek(offset)
|
672
|
+
output = output_file.read().decode('utf-8', errors='replace')
|
673
|
+
new_offset = output_file.tell()
|
541
674
|
status_data = read_command_status(command_id) or {}
|
542
|
-
|
675
|
+
token = app.config.get("TOKEN_URL")
|
676
|
+
token_param = f"&token={token}" if token else ""
|
677
|
+
response = {
|
678
|
+
'output': output[-maxsize:],
|
679
|
+
'status': status_data.get("status"),
|
680
|
+
'links': {
|
681
|
+
'next': f'{request.url_root}command_output/{command_id}?offset={new_offset}&maxsize={maxsize}{token_param}'
|
682
|
+
}
|
683
|
+
}
|
684
|
+
if request.headers.get('Accept') == 'text/plain':
|
685
|
+
return f"{output}\nstatus: {status_data.get('status')}", 200, {'Content-Type': 'text/plain'}
|
686
|
+
return jsonify(response)
|
543
687
|
return jsonify({'error': 'Invalid command_id'}), 404
|
544
688
|
|
545
689
|
@app.route('/executables', methods=['GET'])
|
546
690
|
def list_executables():
|
547
691
|
executables = [f for f in os.listdir('.') if os.path.isfile(f) and os.access(f, os.X_OK)]
|
692
|
+
executables.sort() # Sort the list of executables alphabetically
|
548
693
|
return jsonify(executables)
|
549
694
|
|
695
|
+
@app.route('/popup/<command_id>')
|
696
|
+
def popup(command_id):
|
697
|
+
return render_template('popup.html', command_id=command_id)
|
698
|
+
|
550
699
|
def main():
|
700
|
+
global COMMAND_STATUS_DIR
|
551
701
|
basef = f"{CONFDIR}/pywebexec_{args.listen}:{args.port}"
|
552
|
-
if
|
553
|
-
os.
|
702
|
+
if args.action == "shareterm":
|
703
|
+
COMMAND_STATUS_DIR = f"{os.getcwd()}/{COMMAND_STATUS_DIR}"
|
704
|
+
with open(basef + ".log", "ab+") as log:
|
705
|
+
pywebexec = subprocess.Popen([sys.executable] + sys.argv[:-1], stdout=log, stderr=log)
|
706
|
+
start_term()
|
707
|
+
time.sleep(1)
|
708
|
+
pywebexec.terminate()
|
709
|
+
sys.exit()
|
710
|
+
|
711
|
+
if args.action == "restart":
|
712
|
+
daemon_d('stop', pidfilepath=basef)
|
713
|
+
args.action = "start"
|
554
714
|
if args.action == "start":
|
555
|
-
return start_gunicorn(
|
715
|
+
return start_gunicorn(daemonized=True, baselog=basef)
|
556
716
|
if args.action:
|
557
717
|
return daemon_d(args.action, pidfilepath=basef)
|
558
|
-
return start_gunicorn()
|
718
|
+
return start_gunicorn(baselog=basef)
|
719
|
+
|
559
720
|
|
560
721
|
if __name__ == '__main__':
|
561
722
|
main()
|
Binary file
|