pywebexec 1.2.4__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/__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)
|