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 +5 -0
- pywebexec/pywebexec.py +595 -0
- pywebexec/static/css/style.css +178 -0
- pywebexec/static/css/xterm.css +209 -0
- pywebexec/static/images/aborted.svg +1 -0
- pywebexec/static/images/copy.svg +1 -0
- pywebexec/static/images/copy_ok.svg +1 -0
- pywebexec/static/images/failed.svg +1 -0
- pywebexec/static/images/favicon.svg +1 -0
- pywebexec/static/images/running.gif +0 -0
- pywebexec/static/images/success.svg +1 -0
- pywebexec/static/js/script.js +280 -0
- 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/__init__.py +0 -0
- pywebexec/templates/index.html +48 -0
- pywebexec/version.py +16 -0
- pywebexec-1.2.4.dist-info/LICENSE +21 -0
- pywebexec-1.2.4.dist-info/METADATA +171 -0
- pywebexec-1.2.4.dist-info/RECORD +25 -0
- pywebexec-1.2.4.dist-info/WHEEL +5 -0
- pywebexec-1.2.4.dist-info/entry_points.txt +2 -0
- pywebexec-1.2.4.dist-info/top_level.txt +1 -0
pywebexec/__init__.py
ADDED
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)
|