pywebexec 0.1.0__py3-none-any.whl → 1.0.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 +175 -84
- pywebexec/templates/index.html +57 -57
- pywebexec/version.py +2 -2
- {pywebexec-0.1.0.dist-info → pywebexec-1.0.0.dist-info}/METADATA +23 -5
- {pywebexec-0.1.0.dist-info → pywebexec-1.0.0.dist-info}/RECORD +9 -9
- {pywebexec-0.1.0.dist-info → pywebexec-1.0.0.dist-info}/LICENSE +0 -0
- {pywebexec-0.1.0.dist-info → pywebexec-1.0.0.dist-info}/WHEEL +0 -0
- {pywebexec-0.1.0.dist-info → pywebexec-1.0.0.dist-info}/entry_points.txt +0 -0
- {pywebexec-0.1.0.dist-info → pywebexec-1.0.0.dist-info}/top_level.txt +0 -0
pywebexec/pywebexec.py
CHANGED
|
@@ -11,25 +11,100 @@ import random
|
|
|
11
11
|
import string
|
|
12
12
|
from datetime import datetime
|
|
13
13
|
import shlex
|
|
14
|
-
from gunicorn.app.base import
|
|
14
|
+
from gunicorn.app.base import Application
|
|
15
|
+
from datetime import timezone, timedelta
|
|
16
|
+
import ipaddress
|
|
17
|
+
from socket import gethostname, gethostbyname_ex
|
|
15
18
|
|
|
16
19
|
app = Flask(__name__)
|
|
17
20
|
auth = HTTPBasicAuth()
|
|
18
21
|
|
|
19
|
-
# Directory to store the
|
|
20
|
-
|
|
22
|
+
# Directory to store the command status and output
|
|
23
|
+
COMMAND_STATUS_DIR = '.web_status'
|
|
21
24
|
CONFDIR = os.path.expanduser("~/")
|
|
22
25
|
if os.path.isdir(f"{CONFDIR}/.config"):
|
|
23
26
|
CONFDIR += '/.config'
|
|
24
27
|
CONFDIR += "/.pywebexec"
|
|
25
28
|
|
|
26
|
-
if not os.path.exists(
|
|
27
|
-
os.makedirs(
|
|
29
|
+
if not os.path.exists(COMMAND_STATUS_DIR):
|
|
30
|
+
os.makedirs(COMMAND_STATUS_DIR)
|
|
28
31
|
|
|
29
32
|
def generate_random_password(length=12):
|
|
30
33
|
characters = string.ascii_letters + string.digits + string.punctuation
|
|
31
34
|
return ''.join(random.choice(characters) for i in range(length))
|
|
32
35
|
|
|
36
|
+
|
|
37
|
+
def resolve_hostname(host):
|
|
38
|
+
"""try get fqdn from DNS"""
|
|
39
|
+
try:
|
|
40
|
+
return gethostbyname_ex(host)[0]
|
|
41
|
+
except OSError:
|
|
42
|
+
return host
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def generate_selfsigned_cert(hostname, ip_addresses=None, key=None):
|
|
46
|
+
"""Generates self signed certificate for a hostname, and optional IP addresses.
|
|
47
|
+
from: https://gist.github.com/bloodearnest/9017111a313777b9cce5
|
|
48
|
+
"""
|
|
49
|
+
from cryptography import x509
|
|
50
|
+
from cryptography.x509.oid import NameOID
|
|
51
|
+
from cryptography.hazmat.primitives import hashes
|
|
52
|
+
from cryptography.hazmat.backends import default_backend
|
|
53
|
+
from cryptography.hazmat.primitives import serialization
|
|
54
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
55
|
+
|
|
56
|
+
# Generate our key
|
|
57
|
+
if key is None:
|
|
58
|
+
key = rsa.generate_private_key(
|
|
59
|
+
public_exponent=65537,
|
|
60
|
+
key_size=2048,
|
|
61
|
+
backend=default_backend(),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
name = x509.Name([
|
|
65
|
+
x509.NameAttribute(NameOID.COMMON_NAME, hostname)
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
# best practice seem to be to include the hostname in the SAN, which *SHOULD* mean COMMON_NAME is ignored.
|
|
69
|
+
alt_names = [x509.DNSName(hostname)]
|
|
70
|
+
alt_names.append(x509.DNSName("localhost"))
|
|
71
|
+
|
|
72
|
+
# allow addressing by IP, for when you don't have real DNS (common in most testing scenarios
|
|
73
|
+
if ip_addresses:
|
|
74
|
+
for addr in ip_addresses:
|
|
75
|
+
# openssl wants DNSnames for ips...
|
|
76
|
+
alt_names.append(x509.DNSName(addr))
|
|
77
|
+
# ... whereas golang's crypto/tls is stricter, and needs IPAddresses
|
|
78
|
+
# note: older versions of cryptography do not understand ip_address objects
|
|
79
|
+
alt_names.append(x509.IPAddress(ipaddress.ip_address(addr)))
|
|
80
|
+
san = x509.SubjectAlternativeName(alt_names)
|
|
81
|
+
|
|
82
|
+
# path_len=0 means this cert can only sign itself, not other certs.
|
|
83
|
+
basic_contraints = x509.BasicConstraints(ca=True, path_length=0)
|
|
84
|
+
now = datetime.now(timezone.utc)
|
|
85
|
+
cert = (
|
|
86
|
+
x509.CertificateBuilder()
|
|
87
|
+
.subject_name(name)
|
|
88
|
+
.issuer_name(name)
|
|
89
|
+
.public_key(key.public_key())
|
|
90
|
+
.serial_number(1000)
|
|
91
|
+
.not_valid_before(now)
|
|
92
|
+
.not_valid_after(now + timedelta(days=10*365))
|
|
93
|
+
.add_extension(basic_contraints, False)
|
|
94
|
+
.add_extension(san, False)
|
|
95
|
+
.sign(key, hashes.SHA256(), default_backend())
|
|
96
|
+
)
|
|
97
|
+
cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
|
|
98
|
+
key_pem = key.private_bytes(
|
|
99
|
+
encoding=serialization.Encoding.PEM,
|
|
100
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
101
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return cert_pem, key_pem
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
33
108
|
class StandaloneApplication(Application):
|
|
34
109
|
|
|
35
110
|
def __init__(self, app, options=None):
|
|
@@ -110,7 +185,7 @@ def daemon_d(action, pidfilepath, hostname=None, args=None):
|
|
|
110
185
|
|
|
111
186
|
def parseargs():
|
|
112
187
|
global app, args
|
|
113
|
-
parser = argparse.ArgumentParser(description='Run the
|
|
188
|
+
parser = argparse.ArgumentParser(description='Run the command execution server.')
|
|
114
189
|
parser.add_argument('--user', help='Username for basic auth')
|
|
115
190
|
parser.add_argument('--password', help='Password for basic auth')
|
|
116
191
|
parser.add_argument(
|
|
@@ -126,11 +201,12 @@ def parseargs():
|
|
|
126
201
|
"-t",
|
|
127
202
|
"--title",
|
|
128
203
|
type=str,
|
|
129
|
-
default="
|
|
204
|
+
default="pywebexec",
|
|
130
205
|
help="Web html title",
|
|
131
206
|
)
|
|
132
207
|
parser.add_argument("-c", "--cert", type=str, help="Path to https certificate")
|
|
133
208
|
parser.add_argument("-k", "--key", type=str, help="Path to https certificate key")
|
|
209
|
+
parser.add_argument("-g", "--gencert", action="store_true", help="https server self signed cert")
|
|
134
210
|
parser.add_argument("action", nargs="?", help="daemon action start/stop/restart/status", choices=["start","stop","restart","status"])
|
|
135
211
|
|
|
136
212
|
args = parser.parse_args()
|
|
@@ -144,6 +220,17 @@ def parseargs():
|
|
|
144
220
|
print(f"Error: {args.dir} not found", file=sys.stderr)
|
|
145
221
|
sys.exit(1)
|
|
146
222
|
|
|
223
|
+
if args.gencert:
|
|
224
|
+
hostname = resolve_hostname(gethostname())
|
|
225
|
+
args.cert = args.cert or f"{CONFDIR}/pywebexec.crt"
|
|
226
|
+
args.key = args.key or f"{CONFDIR}/pywebexec.key"
|
|
227
|
+
if not os.path.exists(args.cert):
|
|
228
|
+
(cert, key) = generate_selfsigned_cert(hostname)
|
|
229
|
+
with open(args.cert, "wb") as fd:
|
|
230
|
+
fd.write(cert)
|
|
231
|
+
with open(args.key, "wb") as fd:
|
|
232
|
+
fd.write(key)
|
|
233
|
+
|
|
147
234
|
if args.user:
|
|
148
235
|
app.config['USER'] = args.user
|
|
149
236
|
if args.password:
|
|
@@ -158,18 +245,18 @@ def parseargs():
|
|
|
158
245
|
|
|
159
246
|
parseargs()
|
|
160
247
|
|
|
161
|
-
def get_status_file_path(
|
|
162
|
-
return os.path.join(
|
|
248
|
+
def get_status_file_path(command_id):
|
|
249
|
+
return os.path.join(COMMAND_STATUS_DIR, f'{command_id}.json')
|
|
163
250
|
|
|
164
|
-
def get_output_file_path(
|
|
165
|
-
return os.path.join(
|
|
251
|
+
def get_output_file_path(command_id):
|
|
252
|
+
return os.path.join(COMMAND_STATUS_DIR, f'{command_id}_output.txt')
|
|
166
253
|
|
|
167
|
-
def
|
|
168
|
-
status_file_path = get_status_file_path(
|
|
169
|
-
status_data =
|
|
254
|
+
def update_command_status(command_id, status, command=None, params=None, start_time=None, end_time=None, exit_code=None, pid=None):
|
|
255
|
+
status_file_path = get_status_file_path(command_id)
|
|
256
|
+
status_data = read_command_status(command_id) or {}
|
|
170
257
|
status_data['status'] = status
|
|
171
|
-
if
|
|
172
|
-
status_data['
|
|
258
|
+
if command is not None:
|
|
259
|
+
status_data['command'] = command
|
|
173
260
|
if params is not None:
|
|
174
261
|
status_data['params'] = params
|
|
175
262
|
if start_time is not None:
|
|
@@ -183,8 +270,8 @@ def update_script_status(script_id, status, script_name=None, params=None, start
|
|
|
183
270
|
with open(status_file_path, 'w') as f:
|
|
184
271
|
json.dump(status_data, f)
|
|
185
272
|
|
|
186
|
-
def
|
|
187
|
-
status_file_path = get_status_file_path(
|
|
273
|
+
def read_command_status(command_id):
|
|
274
|
+
status_file_path = get_status_file_path(command_id)
|
|
188
275
|
if not os.path.exists(status_file_path):
|
|
189
276
|
return None
|
|
190
277
|
with open(status_file_path, 'r') as f:
|
|
@@ -193,31 +280,31 @@ def read_script_status(script_id):
|
|
|
193
280
|
# Dictionary to store the process objects
|
|
194
281
|
processes = {}
|
|
195
282
|
|
|
196
|
-
def
|
|
283
|
+
def run_command(command, params, command_id):
|
|
197
284
|
start_time = datetime.now().isoformat()
|
|
198
|
-
|
|
285
|
+
update_command_status(command_id, 'running', command=command, params=params, start_time=start_time)
|
|
199
286
|
try:
|
|
200
|
-
output_file_path = get_output_file_path(
|
|
287
|
+
output_file_path = get_output_file_path(command_id)
|
|
201
288
|
with open(output_file_path, 'w') as output_file:
|
|
202
|
-
# Run the
|
|
203
|
-
process = subprocess.Popen([
|
|
204
|
-
|
|
205
|
-
processes[
|
|
289
|
+
# Run the command with parameters and redirect stdout and stderr to the file
|
|
290
|
+
process = subprocess.Popen([command] + params, stdout=output_file, stderr=output_file, bufsize=0) #text=True)
|
|
291
|
+
update_command_status(command_id, 'running', pid=process.pid)
|
|
292
|
+
processes[command_id] = process
|
|
206
293
|
process.wait()
|
|
207
|
-
processes.pop(
|
|
294
|
+
processes.pop(command_id, None)
|
|
208
295
|
|
|
209
296
|
end_time = datetime.now().isoformat()
|
|
210
297
|
# Update the status based on the result
|
|
211
298
|
if process.returncode == 0:
|
|
212
|
-
|
|
299
|
+
update_command_status(command_id, 'success', end_time=end_time, exit_code=process.returncode)
|
|
213
300
|
elif process.returncode == -15:
|
|
214
|
-
|
|
301
|
+
update_command_status(command_id, 'aborted', end_time=end_time, exit_code=process.returncode)
|
|
215
302
|
else:
|
|
216
|
-
|
|
303
|
+
update_command_status(command_id, 'failed', end_time=end_time, exit_code=process.returncode)
|
|
217
304
|
except Exception as e:
|
|
218
305
|
end_time = datetime.now().isoformat()
|
|
219
|
-
|
|
220
|
-
with open(get_output_file_path(
|
|
306
|
+
update_command_status(command_id, 'failed', end_time=end_time, exit_code=1)
|
|
307
|
+
with open(get_output_file_path(command_id), 'a') as output_file:
|
|
221
308
|
output_file.write(str(e))
|
|
222
309
|
|
|
223
310
|
def auth_required(f):
|
|
@@ -225,20 +312,20 @@ def auth_required(f):
|
|
|
225
312
|
return auth.login_required(f)
|
|
226
313
|
return f
|
|
227
314
|
|
|
228
|
-
@app.route('/
|
|
315
|
+
@app.route('/run_command', methods=['POST'])
|
|
229
316
|
@auth_required
|
|
230
|
-
def
|
|
317
|
+
def run_command_endpoint():
|
|
231
318
|
data = request.json
|
|
232
|
-
|
|
319
|
+
command = data.get('command')
|
|
233
320
|
params = data.get('params', [])
|
|
234
321
|
|
|
235
|
-
if not
|
|
236
|
-
return jsonify({'error': '
|
|
322
|
+
if not command:
|
|
323
|
+
return jsonify({'error': 'command is required'}), 400
|
|
237
324
|
|
|
238
|
-
# Ensure the
|
|
239
|
-
|
|
240
|
-
if not os.path.isfile(
|
|
241
|
-
return jsonify({'error': '
|
|
325
|
+
# Ensure the command is an executable in the current directory
|
|
326
|
+
command_path = os.path.join(".", os.path.basename(command))
|
|
327
|
+
if not os.path.isfile(command_path) or not os.access(command_path, os.X_OK):
|
|
328
|
+
return jsonify({'error': 'command must be an executable in the current directory'}), 400
|
|
242
329
|
|
|
243
330
|
# Split params using shell-like syntax
|
|
244
331
|
try:
|
|
@@ -246,94 +333,98 @@ def run_script_endpoint():
|
|
|
246
333
|
except ValueError as e:
|
|
247
334
|
return jsonify({'error': str(e)}), 400
|
|
248
335
|
|
|
249
|
-
# Generate a unique
|
|
250
|
-
|
|
336
|
+
# Generate a unique command_id
|
|
337
|
+
command_id = str(uuid.uuid4())
|
|
251
338
|
|
|
252
|
-
# Set the initial status to running and save
|
|
253
|
-
|
|
339
|
+
# Set the initial status to running and save command details
|
|
340
|
+
update_command_status(command_id, 'running', command, params)
|
|
254
341
|
|
|
255
|
-
# Run the
|
|
256
|
-
thread = threading.Thread(target=
|
|
342
|
+
# Run the command in a separate thread
|
|
343
|
+
thread = threading.Thread(target=run_command, args=(command_path, params, command_id))
|
|
257
344
|
thread.start()
|
|
258
345
|
|
|
259
|
-
return jsonify({'message': '
|
|
346
|
+
return jsonify({'message': 'Command is running', 'command_id': command_id})
|
|
260
347
|
|
|
261
|
-
@app.route('/
|
|
348
|
+
@app.route('/stop_command/<command_id>', methods=['POST'])
|
|
262
349
|
@auth_required
|
|
263
|
-
def
|
|
264
|
-
status =
|
|
350
|
+
def stop_command(command_id):
|
|
351
|
+
status = read_command_status(command_id)
|
|
265
352
|
if not status or 'pid' not in status:
|
|
266
|
-
return jsonify({'error': 'Invalid
|
|
353
|
+
return jsonify({'error': 'Invalid command_id or command not running'}), 400
|
|
267
354
|
|
|
268
355
|
pid = status['pid']
|
|
269
356
|
end_time = datetime.now().isoformat()
|
|
270
357
|
try:
|
|
271
358
|
os.kill(pid, 15) # Send SIGTERM
|
|
272
|
-
|
|
273
|
-
return jsonify({'message': '
|
|
359
|
+
update_command_status(command_id, 'aborted', end_time=end_time, exit_code=-15)
|
|
360
|
+
return jsonify({'message': 'Command aborted'})
|
|
274
361
|
except Exception as e:
|
|
275
|
-
status_data =
|
|
362
|
+
status_data = read_command_status(command_id) or {}
|
|
276
363
|
status_data['status'] = 'failed'
|
|
277
364
|
status_data['end_time'] = end_time
|
|
278
365
|
status_data['exit_code'] = 1
|
|
279
|
-
with open(get_status_file_path(
|
|
366
|
+
with open(get_status_file_path(command_id), 'w') as f:
|
|
280
367
|
json.dump(status_data, f)
|
|
281
|
-
with open(get_output_file_path(
|
|
368
|
+
with open(get_output_file_path(command_id), 'a') as output_file:
|
|
282
369
|
output_file.write(str(e))
|
|
283
|
-
return jsonify({'error': 'Failed to terminate
|
|
370
|
+
return jsonify({'error': 'Failed to terminate command'}), 500
|
|
284
371
|
|
|
285
|
-
@app.route('/
|
|
372
|
+
@app.route('/command_status/<command_id>', methods=['GET'])
|
|
286
373
|
@auth_required
|
|
287
|
-
def
|
|
288
|
-
status =
|
|
374
|
+
def get_command_status(command_id):
|
|
375
|
+
status = read_command_status(command_id)
|
|
289
376
|
if not status:
|
|
290
|
-
return jsonify({'error': 'Invalid
|
|
377
|
+
return jsonify({'error': 'Invalid command_id'}), 404
|
|
291
378
|
|
|
292
|
-
output_file_path = get_output_file_path(
|
|
293
|
-
if os.path.exists(output_file_path):
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
379
|
+
# output_file_path = get_output_file_path(command_id)
|
|
380
|
+
# if os.path.exists(output_file_path):
|
|
381
|
+
# with open(output_file_path, 'r') as output_file:
|
|
382
|
+
# output = output_file.read()
|
|
383
|
+
# status['output'] = output
|
|
297
384
|
|
|
298
385
|
return jsonify(status)
|
|
299
386
|
|
|
300
387
|
@app.route('/')
|
|
301
388
|
@auth_required
|
|
302
389
|
def index():
|
|
303
|
-
return render_template('index.html')
|
|
390
|
+
return render_template('index.html', title=args.title)
|
|
304
391
|
|
|
305
|
-
@app.route('/
|
|
392
|
+
@app.route('/commands', methods=['GET'])
|
|
306
393
|
@auth_required
|
|
307
|
-
def
|
|
308
|
-
|
|
309
|
-
for filename in os.listdir(
|
|
394
|
+
def list_commands():
|
|
395
|
+
commands = []
|
|
396
|
+
for filename in os.listdir(COMMAND_STATUS_DIR):
|
|
310
397
|
if filename.endswith('.json'):
|
|
311
|
-
|
|
312
|
-
status =
|
|
398
|
+
command_id = filename[:-5]
|
|
399
|
+
status = read_command_status(command_id)
|
|
313
400
|
if status:
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
401
|
+
try:
|
|
402
|
+
params = shlex.join(status['params'])
|
|
403
|
+
except AttributeError:
|
|
404
|
+
params = " ".join([shlex.quote(p) if " " in p else p for p in status['params']])
|
|
405
|
+
command = status['command'] + ' ' + params
|
|
406
|
+
commands.append({
|
|
407
|
+
'command_id': command_id,
|
|
317
408
|
'status': status['status'],
|
|
318
409
|
'start_time': status.get('start_time', 'N/A'),
|
|
319
410
|
'end_time': status.get('end_time', 'N/A'),
|
|
320
411
|
'command': command,
|
|
321
412
|
'exit_code': status.get('exit_code', 'N/A')
|
|
322
413
|
})
|
|
323
|
-
# Sort
|
|
324
|
-
|
|
325
|
-
return jsonify(
|
|
414
|
+
# Sort commands by start_time in descending order
|
|
415
|
+
commands.sort(key=lambda x: x['start_time'], reverse=True)
|
|
416
|
+
return jsonify(commands)
|
|
326
417
|
|
|
327
|
-
@app.route('/
|
|
418
|
+
@app.route('/command_output/<command_id>', methods=['GET'])
|
|
328
419
|
@auth_required
|
|
329
|
-
def
|
|
330
|
-
output_file_path = get_output_file_path(
|
|
420
|
+
def get_command_output(command_id):
|
|
421
|
+
output_file_path = get_output_file_path(command_id)
|
|
331
422
|
if os.path.exists(output_file_path):
|
|
332
423
|
with open(output_file_path, 'r') as output_file:
|
|
333
424
|
output = output_file.read()
|
|
334
|
-
status_data =
|
|
425
|
+
status_data = read_command_status(command_id) or {}
|
|
335
426
|
return jsonify({'output': output, 'status': status_data.get("status")})
|
|
336
|
-
return jsonify({'error': 'Invalid
|
|
427
|
+
return jsonify({'error': 'Invalid command_id'}), 404
|
|
337
428
|
|
|
338
429
|
@app.route('/executables', methods=['GET'])
|
|
339
430
|
@auth_required
|
pywebexec/templates/index.html
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
|
-
<title>
|
|
5
|
+
<title>{{ title }}</title>
|
|
6
6
|
<style>
|
|
7
7
|
body { font-family: Arial, sans-serif; }
|
|
8
8
|
.table-container { height: 380px; overflow-y: auto; position: relative; }
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
input {
|
|
84
84
|
width: 50%
|
|
85
85
|
}
|
|
86
|
-
.
|
|
86
|
+
.currentcommand {
|
|
87
87
|
background-color: #eef;
|
|
88
88
|
}
|
|
89
89
|
.resizer {
|
|
@@ -103,11 +103,11 @@
|
|
|
103
103
|
</style>
|
|
104
104
|
</head>
|
|
105
105
|
<body>
|
|
106
|
-
<h1>
|
|
106
|
+
<h1>{{ title }}</h1>
|
|
107
107
|
<form id="launchForm">
|
|
108
|
-
<label for="
|
|
109
|
-
<select id="
|
|
110
|
-
<label for="params">Params
|
|
108
|
+
<label for="commandName">Command</label>
|
|
109
|
+
<select id="commandName" name="commandName"></select>
|
|
110
|
+
<label for="params">Params</label>
|
|
111
111
|
<input type="text" id="params" name="params">
|
|
112
112
|
<button type="submit">Launch</button>
|
|
113
113
|
</form>
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
<table>
|
|
116
116
|
<thead>
|
|
117
117
|
<tr>
|
|
118
|
-
<th>
|
|
118
|
+
<th>Command ID</th>
|
|
119
119
|
<th>Status</th>
|
|
120
120
|
<th>Start Time</th>
|
|
121
121
|
<th>Duration</th>
|
|
@@ -124,7 +124,7 @@
|
|
|
124
124
|
<th>Actions</th>
|
|
125
125
|
</tr>
|
|
126
126
|
</thead>
|
|
127
|
-
<tbody id="
|
|
127
|
+
<tbody id="commands"></tbody>
|
|
128
128
|
</table>
|
|
129
129
|
</div>
|
|
130
130
|
<div class="resizer-container">
|
|
@@ -133,69 +133,69 @@
|
|
|
133
133
|
<div id="output" class="output"></div>
|
|
134
134
|
|
|
135
135
|
<script>
|
|
136
|
-
let
|
|
136
|
+
let currentCommandId = null;
|
|
137
137
|
let outputInterval = null;
|
|
138
138
|
|
|
139
139
|
document.getElementById('launchForm').addEventListener('submit', async (event) => {
|
|
140
140
|
event.preventDefault();
|
|
141
|
-
const
|
|
141
|
+
const commandName = document.getElementById('commandName').value;
|
|
142
142
|
const params = document.getElementById('params').value.split(' ');
|
|
143
|
-
const response = await fetch('/
|
|
143
|
+
const response = await fetch('/run_command', {
|
|
144
144
|
method: 'POST',
|
|
145
145
|
headers: {
|
|
146
146
|
'Content-Type': 'application/json'
|
|
147
147
|
},
|
|
148
|
-
body: JSON.stringify({
|
|
148
|
+
body: JSON.stringify({ command: commandName, params: params })
|
|
149
149
|
});
|
|
150
150
|
const data = await response.json();
|
|
151
|
-
|
|
152
|
-
viewOutput(data.
|
|
151
|
+
fetchCommands();
|
|
152
|
+
viewOutput(data.command_id);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
-
async function
|
|
156
|
-
const response = await fetch('/
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
155
|
+
async function fetchCommands() {
|
|
156
|
+
const response = await fetch('/commands');
|
|
157
|
+
const commands = await response.json();
|
|
158
|
+
commands.sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
|
|
159
|
+
const commandsTbody = document.getElementById('commands');
|
|
160
|
+
commandsTbody.innerHTML = '';
|
|
161
|
+
commands.forEach(command => {
|
|
162
|
+
const commandRow = document.createElement('tr');
|
|
163
|
+
commandRow.className = command.command_id === currentCommandId ? 'currentcommand' : '';
|
|
164
|
+
commandRow.innerHTML = `
|
|
165
165
|
<td class="monospace">
|
|
166
|
-
<span class="copy_clip" onclick="copyToClipboard('${
|
|
166
|
+
<span class="copy_clip" onclick="copyToClipboard('${command.command_id}', this)">${command.command_id.slice(0, 8)}</span>
|
|
167
167
|
</td>
|
|
168
|
-
<td><span class="status-icon status-${
|
|
169
|
-
<td>${formatTime(
|
|
170
|
-
<td>${
|
|
171
|
-
<td>${
|
|
172
|
-
<td>${
|
|
168
|
+
<td><span class="status-icon status-${command.status}"></span>${command.status}</td>
|
|
169
|
+
<td>${formatTime(command.start_time)}</td>
|
|
170
|
+
<td>${command.status === 'running' ? formatDuration(command.start_time, new Date().toISOString()) : formatDuration(command.start_time, command.end_time)}</td>
|
|
171
|
+
<td>${command.exit_code}</td>
|
|
172
|
+
<td>${command.command.replace(/^\.\//, '')}</td>
|
|
173
173
|
<td>
|
|
174
|
-
<button onclick="viewOutput('${
|
|
175
|
-
<button onclick="
|
|
176
|
-
${
|
|
174
|
+
<button onclick="viewOutput('${command.command_id}')">Log</button>
|
|
175
|
+
<button onclick="relaunchCommand('${command.command_id}')">Relaunch</button>
|
|
176
|
+
${command.status === 'running' ? `<button onclick="stopCommand('${command.command_id}')">Stop</button>` : ''}
|
|
177
177
|
</td>
|
|
178
178
|
`;
|
|
179
|
-
|
|
179
|
+
commandsTbody.appendChild(commandRow);
|
|
180
180
|
});
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
async function fetchExecutables() {
|
|
184
184
|
const response = await fetch('/executables');
|
|
185
185
|
const executables = await response.json();
|
|
186
|
-
const
|
|
187
|
-
|
|
186
|
+
const commandNameSelect = document.getElementById('commandName');
|
|
187
|
+
commandNameSelect.innerHTML = '';
|
|
188
188
|
executables.forEach(executable => {
|
|
189
189
|
const option = document.createElement('option');
|
|
190
190
|
option.value = executable;
|
|
191
191
|
option.textContent = executable;
|
|
192
|
-
|
|
192
|
+
commandNameSelect.appendChild(option);
|
|
193
193
|
});
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
async function fetchOutput(
|
|
196
|
+
async function fetchOutput(command_id) {
|
|
197
197
|
const outputDiv = document.getElementById('output');
|
|
198
|
-
const response = await fetch(`/
|
|
198
|
+
const response = await fetch(`/command_output/${command_id}`);
|
|
199
199
|
const data = await response.json();
|
|
200
200
|
if (data.error) {
|
|
201
201
|
outputDiv.innerHTML = data.error;
|
|
@@ -209,45 +209,45 @@
|
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
async function viewOutput(
|
|
212
|
+
async function viewOutput(command_id) {
|
|
213
213
|
adjustOutputHeight();
|
|
214
|
-
|
|
214
|
+
currentCommandId = command_id;
|
|
215
215
|
clearInterval(outputInterval);
|
|
216
|
-
const response = await fetch(`/
|
|
216
|
+
const response = await fetch(`/command_status/${command_id}`);
|
|
217
217
|
const data = await response.json();
|
|
218
218
|
if (data.status === 'running') {
|
|
219
|
-
fetchOutput(
|
|
220
|
-
outputInterval = setInterval(() => fetchOutput(
|
|
219
|
+
fetchOutput(command_id);
|
|
220
|
+
outputInterval = setInterval(() => fetchOutput(command_id), 1000);
|
|
221
221
|
} else {
|
|
222
|
-
fetchOutput(
|
|
222
|
+
fetchOutput(command_id);
|
|
223
223
|
}
|
|
224
|
-
|
|
224
|
+
fetchCommands(); // Refresh the command list to highlight the current command
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
async function
|
|
228
|
-
const response = await fetch(`/
|
|
227
|
+
async function relaunchCommand(command_id) {
|
|
228
|
+
const response = await fetch(`/command_status/${command_id}`);
|
|
229
229
|
const data = await response.json();
|
|
230
230
|
if (data.error) {
|
|
231
231
|
alert(data.error);
|
|
232
232
|
return;
|
|
233
233
|
}
|
|
234
|
-
const relaunchResponse = await fetch('/
|
|
234
|
+
const relaunchResponse = await fetch('/run_command', {
|
|
235
235
|
method: 'POST',
|
|
236
236
|
headers: {
|
|
237
237
|
'Content-Type': 'application/json'
|
|
238
238
|
},
|
|
239
239
|
body: JSON.stringify({
|
|
240
|
-
|
|
240
|
+
command: data.command,
|
|
241
241
|
params: data.params
|
|
242
242
|
})
|
|
243
243
|
});
|
|
244
244
|
const relaunchData = await relaunchResponse.json();
|
|
245
|
-
|
|
246
|
-
viewOutput(relaunchData.
|
|
245
|
+
fetchCommands();
|
|
246
|
+
viewOutput(relaunchData.command_id);
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
async function
|
|
250
|
-
const response = await fetch(`/
|
|
249
|
+
async function stopCommand(command_id) {
|
|
250
|
+
const response = await fetch(`/stop_command/${command_id}`, {
|
|
251
251
|
method: 'POST'
|
|
252
252
|
});
|
|
253
253
|
const data = await response.json();
|
|
@@ -255,7 +255,7 @@
|
|
|
255
255
|
alert(data.error);
|
|
256
256
|
} else {
|
|
257
257
|
alert(data.message);
|
|
258
|
-
|
|
258
|
+
fetchCommands();
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
@@ -322,9 +322,9 @@
|
|
|
322
322
|
initResizer();
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
-
|
|
325
|
+
fetchCommands();
|
|
326
326
|
fetchExecutables();
|
|
327
|
-
setInterval(
|
|
327
|
+
setInterval(fetchCommands, 5000);
|
|
328
328
|
</script>
|
|
329
329
|
</body>
|
|
330
330
|
</html>
|
pywebexec/version.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: pywebexec
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Simple Python HTTP Exec Server
|
|
5
5
|
Home-page: https://github.com/joknarf/pywebexec
|
|
6
6
|
Author: Franck Jouvanceau
|
|
@@ -85,7 +85,7 @@ $ pywebexec
|
|
|
85
85
|
|
|
86
86
|
## features
|
|
87
87
|
|
|
88
|
-
* Serve executables in
|
|
88
|
+
* Serve executables in a directory
|
|
89
89
|
* Launch commands with params from web browser or API call
|
|
90
90
|
* Follow live output
|
|
91
91
|
* Stop command
|
|
@@ -94,11 +94,12 @@ $ pywebexec
|
|
|
94
94
|
* HTTPS self-signed certificate generator
|
|
95
95
|
* Can be started as a daemon (POSIX)
|
|
96
96
|
* uses gunicorn to serve http/https
|
|
97
|
+
* compatible Linux/MacOS
|
|
97
98
|
|
|
98
99
|
## Customize server
|
|
99
100
|
```
|
|
100
|
-
$ pywebexec --listen 0.0.0.0 --port 8080
|
|
101
|
-
$ pywebexec -l 0.0.0.0 -p 8080
|
|
101
|
+
$ pywebexec --dir ~/myscripts --listen 0.0.0.0 --port 8080
|
|
102
|
+
$ pywebexec -d ~/myscripts -l 0.0.0.0 -p 8080
|
|
102
103
|
```
|
|
103
104
|
|
|
104
105
|
## Basic auth user/password
|
|
@@ -122,7 +123,7 @@ $ pywebfs --cert /pathto/host.cert --key /pathto/host.key
|
|
|
122
123
|
$ pywebfs -c /pathto/host.cert -k /pathto/host.key
|
|
123
124
|
```
|
|
124
125
|
|
|
125
|
-
## Launch server as a daemon
|
|
126
|
+
## Launch server as a daemon
|
|
126
127
|
|
|
127
128
|
```
|
|
128
129
|
$ pywebexec start
|
|
@@ -131,3 +132,20 @@ $ pywebexec stop
|
|
|
131
132
|
```
|
|
132
133
|
* log of server are stored in directory `[.config/].pywebexec/pywebexec_<listen>:<port>.log`
|
|
133
134
|
|
|
135
|
+
## Launch command through API
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
# curl http://myhost:8080/run_script -H 'Content-Type: application/json' -X POST -d '{ "script_name":"myscript", "param":["param1", ...]}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## API reference
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
| method | route | params/payload | returns
|
|
145
|
+
|-----------|-----------------------------|--------------------|---------------------|
|
|
146
|
+
| POST | /run_command | command: str<br>params: array[str] | command_id: uuid<br>message: str |
|
|
147
|
+
| POST | /stop_command/command_id | | message: str |
|
|
148
|
+
| GET | /command_status/command_id | | command_id: uuid<br>command: str<br>params: array[str]<br>start_time: isotime<br>end_time: isotime<br>status: str<br>exit_code: int |
|
|
149
|
+
| GET | /command_output/command_id | | output: str<br>status: str |
|
|
150
|
+
| GET | /commands | | array of<br>command_id: uuid<br>command: str<br>start_time: isotime<br>end_time: isotime<br>status: str<br>exit_code: int |
|
|
151
|
+
| GET | /executables | | array of str |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
pywebexec/__init__.py,sha256=4spIsVaF8RJt8S58AG_wWoORRNkws9Iwqprj27C3ljM,99
|
|
2
|
-
pywebexec/pywebexec.py,sha256=
|
|
3
|
-
pywebexec/version.py,sha256=
|
|
2
|
+
pywebexec/pywebexec.py,sha256=2lVnQd9BgaUunjUtgYKIwgIRllX11v64jZQgfweteFg,16281
|
|
3
|
+
pywebexec/version.py,sha256=DGJ4pj32xs3_DRJhSzQwCiRNnAQrMgo09USYpyMZsKc,411
|
|
4
4
|
pywebexec/static/images/aborted.svg,sha256=_mP43hU5QdRLFZIknBgjx-dIXrHgQG23-QV27ApXK2A,381
|
|
5
5
|
pywebexec/static/images/copy.svg,sha256=d9OwtGh5GzzZHzYcDrLfNxZYLth1Q64x7bRyYxu4Px0,622
|
|
6
6
|
pywebexec/static/images/copy_ok.svg,sha256=mEqUVUhSq8xaJK2msQkxRawnz_KwlCZ-tok8QS6hJ3g,451
|
|
@@ -8,10 +8,10 @@ pywebexec/static/images/failed.svg,sha256=ADZ7IKrUyOXtqpivnz3VcH0-Wru-I5MOi3OJAk
|
|
|
8
8
|
pywebexec/static/images/running.svg,sha256=vBpiG6ClNUNCArkwsyqK7O-qhIKJX1NI7MSjclNSp_8,1537
|
|
9
9
|
pywebexec/static/images/success.svg,sha256=PJDcCSTevJh7rkfSFLtc7P0pbeh8PVQBS8DaOLQemmc,489
|
|
10
10
|
pywebexec/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
pywebexec/templates/index.html,sha256=
|
|
12
|
-
pywebexec-
|
|
13
|
-
pywebexec-
|
|
14
|
-
pywebexec-
|
|
15
|
-
pywebexec-
|
|
16
|
-
pywebexec-
|
|
17
|
-
pywebexec-
|
|
11
|
+
pywebexec/templates/index.html,sha256=taMeaWiX7ijHI-kWVyjIBH0E0Fj5RY7-9ocOXOiTZCk,12431
|
|
12
|
+
pywebexec-1.0.0.dist-info/LICENSE,sha256=gRJf0JPT_wsZJsUGlWPTS8Vypfl9vQ1qjp6sNbKykuA,1064
|
|
13
|
+
pywebexec-1.0.0.dist-info/METADATA,sha256=CrooVWXHqkcV0PtBpWzHedlZVLvoR0b77O_MxojvK6E,5970
|
|
14
|
+
pywebexec-1.0.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
15
|
+
pywebexec-1.0.0.dist-info/entry_points.txt,sha256=l52GBkPCXRkmlHfEyoVauyfBdg8o-CAtC8qQpOIjJK0,55
|
|
16
|
+
pywebexec-1.0.0.dist-info/top_level.txt,sha256=vHoHyzngrfGdm_nM7Xn_5iLmaCrf10XO1EhldgNLEQ8,10
|
|
17
|
+
pywebexec-1.0.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|