pywebexec 1.0.0__tar.gz → 1.1.1__tar.gz

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.
Files changed (28) hide show
  1. {pywebexec-1.0.0/pywebexec.egg-info → pywebexec-1.1.1}/PKG-INFO +29 -12
  2. {pywebexec-1.0.0 → pywebexec-1.1.1}/README.md +27 -11
  3. {pywebexec-1.0.0 → pywebexec-1.1.1}/pyproject.toml +1 -0
  4. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec/pywebexec.py +135 -23
  5. pywebexec-1.1.1/pywebexec/static/css/style.css +112 -0
  6. pywebexec-1.1.1/pywebexec/static/images/favicon.svg +1 -0
  7. pywebexec-1.1.1/pywebexec/static/js/script.js +197 -0
  8. pywebexec-1.1.1/pywebexec/templates/index.html +41 -0
  9. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec/version.py +2 -2
  10. {pywebexec-1.0.0 → pywebexec-1.1.1/pywebexec.egg-info}/PKG-INFO +29 -12
  11. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec.egg-info/SOURCES.txt +3 -0
  12. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec.egg-info/requires.txt +1 -0
  13. pywebexec-1.0.0/pywebexec/templates/index.html +0 -330
  14. {pywebexec-1.0.0 → pywebexec-1.1.1}/.github/workflows/python-publish.yml +0 -0
  15. {pywebexec-1.0.0 → pywebexec-1.1.1}/.gitignore +0 -0
  16. {pywebexec-1.0.0 → pywebexec-1.1.1}/LICENSE +0 -0
  17. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec/__init__.py +0 -0
  18. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec/static/images/aborted.svg +0 -0
  19. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec/static/images/copy.svg +0 -0
  20. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec/static/images/copy_ok.svg +0 -0
  21. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec/static/images/failed.svg +0 -0
  22. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec/static/images/running.svg +0 -0
  23. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec/static/images/success.svg +0 -0
  24. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec/templates/__init__.py +0 -0
  25. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec.egg-info/dependency_links.txt +0 -0
  26. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec.egg-info/entry_points.txt +0 -0
  27. {pywebexec-1.0.0 → pywebexec-1.1.1}/pywebexec.egg-info/top_level.txt +0 -0
  28. {pywebexec-1.0.0 → pywebexec-1.1.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: pywebexec
3
- Version: 1.0.0
3
+ Version: 1.1.1
4
4
  Summary: Simple Python HTTP Exec Server
5
5
  Home-page: https://github.com/joknarf/pywebexec
6
6
  Author: Franck Jouvanceau
@@ -58,6 +58,7 @@ Requires-Dist: cryptography>=40.0.2
58
58
  Requires-Dist: Flask>=2.0.3
59
59
  Requires-Dist: Flask-HTTPAuth>=4.8.0
60
60
  Requires-Dist: gunicorn>=21.2.0
61
+ Requires-Dist: ldap3>=2.9.1
61
62
 
62
63
  [![Pypi version](https://img.shields.io/pypi/v/pywebexec.svg)](https://pypi.org/project/pywebexec/)
63
64
  ![example](https://github.com/joknarf/pywebexec/actions/workflows/python-publish.yml/badge.svg)
@@ -75,13 +76,14 @@ $ pip install pywebexec
75
76
 
76
77
  ## Quick start
77
78
 
79
+ * put in a directory the scripts/commands/links to commands you want to expose
78
80
  * start http server serving current directory executables listening on 0.0.0.0 port 8080
79
- ```
81
+ ```shell
80
82
  $ pywebexec
81
83
  ```
82
84
 
83
85
  * Launch commands with params/view live output/Status using browser
84
- ![image](https://github.com/user-attachments/assets/921da56f-6d4b-46e3-b16c-e01a2dc9accf)
86
+ ![pywebexec](https://github.com/user-attachments/assets/d352cc23-1552-4b79-a6ff-f02f05cf328e)
85
87
 
86
88
  ## features
87
89
 
@@ -92,40 +94,55 @@ $ pywebexec
92
94
  * Relaunch command
93
95
  * HTTPS support
94
96
  * HTTPS self-signed certificate generator
97
+ * Basic Auth
98
+ * LDAP(S)
95
99
  * Can be started as a daemon (POSIX)
96
100
  * uses gunicorn to serve http/https
97
101
  * compatible Linux/MacOS
98
102
 
99
103
  ## Customize server
100
- ```
104
+ ```shell
101
105
  $ pywebexec --dir ~/myscripts --listen 0.0.0.0 --port 8080
102
106
  $ pywebexec -d ~/myscripts -l 0.0.0.0 -p 8080
103
107
  ```
104
108
 
105
- ## Basic auth user/password
106
- ```
109
+ ## Basic auth
110
+
111
+ * single user/password
112
+ ```shell
107
113
  $ pywebexec --user myuser [--password mypass]
108
- $ pywebfs -u myuser [-P mypass]
114
+ $ pywebexec -u myuser [-P mypass]
109
115
  ```
110
116
  Generated password is given if no `--pasword` option
111
117
 
118
+ * ldap(s) password check / group member
119
+ ```shell
120
+ $ export PYWEBEXEC_LDAP_SERVER=ldap.forumsys.com
121
+ $ export PYWEBEXEC_LDAP_USE_SSL=0
122
+ $ export PYWEBEXEC_LDAP_BIND_DN="cn=read-only-admin,dc=example,dc=com"
123
+ $ export PYWEBEXEC_LDAP_BIND_PASSWORD="password"
124
+ $ export PYWEBEXEC_LDAP_GROUPS=mathematicians,scientists
125
+ $ export PYWEBEXEC_LDAP_USER_ID="uid"
126
+ $ export PYWEBEXEC_LDAP_BASE_DN="dc=example,dc=com"
127
+ $ pywebexec
128
+ ```
112
129
  ## HTTPS server
113
130
 
114
131
  * Generate auto-signed certificate and start https server
115
- ```
132
+ ```shell
116
133
  $ pywebfs --gencert
117
134
  $ pywebfs --g
118
135
  ```
119
136
 
120
137
  * Start https server using existing certificate
121
- ```
138
+ ```shell
122
139
  $ pywebfs --cert /pathto/host.cert --key /pathto/host.key
123
140
  $ pywebfs -c /pathto/host.cert -k /pathto/host.key
124
141
  ```
125
142
 
126
143
  ## Launch server as a daemon
127
144
 
128
- ```
145
+ ```shell
129
146
  $ pywebexec start
130
147
  $ pywebexec status
131
148
  $ pywebexec stop
@@ -134,8 +151,8 @@ $ pywebexec stop
134
151
 
135
152
  ## Launch command through API
136
153
 
137
- ```
138
- # curl http://myhost:8080/run_script -H 'Content-Type: application/json' -X POST -d '{ "script_name":"myscript", "param":["param1", ...]}
154
+ ```shell
155
+ $ curl http://myhost:8080/run_script -H 'Content-Type: application/json' -X POST -d '{ "script_name":"myscript", "param":["param1", ...]}
139
156
  ```
140
157
 
141
158
  ## API reference
@@ -14,13 +14,14 @@ $ pip install pywebexec
14
14
 
15
15
  ## Quick start
16
16
 
17
+ * put in a directory the scripts/commands/links to commands you want to expose
17
18
  * start http server serving current directory executables listening on 0.0.0.0 port 8080
18
- ```
19
+ ```shell
19
20
  $ pywebexec
20
21
  ```
21
22
 
22
23
  * Launch commands with params/view live output/Status using browser
23
- ![image](https://github.com/user-attachments/assets/921da56f-6d4b-46e3-b16c-e01a2dc9accf)
24
+ ![pywebexec](https://github.com/user-attachments/assets/d352cc23-1552-4b79-a6ff-f02f05cf328e)
24
25
 
25
26
  ## features
26
27
 
@@ -31,40 +32,55 @@ $ pywebexec
31
32
  * Relaunch command
32
33
  * HTTPS support
33
34
  * HTTPS self-signed certificate generator
35
+ * Basic Auth
36
+ * LDAP(S)
34
37
  * Can be started as a daemon (POSIX)
35
38
  * uses gunicorn to serve http/https
36
39
  * compatible Linux/MacOS
37
40
 
38
41
  ## Customize server
39
- ```
42
+ ```shell
40
43
  $ pywebexec --dir ~/myscripts --listen 0.0.0.0 --port 8080
41
44
  $ pywebexec -d ~/myscripts -l 0.0.0.0 -p 8080
42
45
  ```
43
46
 
44
- ## Basic auth user/password
45
- ```
47
+ ## Basic auth
48
+
49
+ * single user/password
50
+ ```shell
46
51
  $ pywebexec --user myuser [--password mypass]
47
- $ pywebfs -u myuser [-P mypass]
52
+ $ pywebexec -u myuser [-P mypass]
48
53
  ```
49
54
  Generated password is given if no `--pasword` option
50
55
 
56
+ * ldap(s) password check / group member
57
+ ```shell
58
+ $ export PYWEBEXEC_LDAP_SERVER=ldap.forumsys.com
59
+ $ export PYWEBEXEC_LDAP_USE_SSL=0
60
+ $ export PYWEBEXEC_LDAP_BIND_DN="cn=read-only-admin,dc=example,dc=com"
61
+ $ export PYWEBEXEC_LDAP_BIND_PASSWORD="password"
62
+ $ export PYWEBEXEC_LDAP_GROUPS=mathematicians,scientists
63
+ $ export PYWEBEXEC_LDAP_USER_ID="uid"
64
+ $ export PYWEBEXEC_LDAP_BASE_DN="dc=example,dc=com"
65
+ $ pywebexec
66
+ ```
51
67
  ## HTTPS server
52
68
 
53
69
  * Generate auto-signed certificate and start https server
54
- ```
70
+ ```shell
55
71
  $ pywebfs --gencert
56
72
  $ pywebfs --g
57
73
  ```
58
74
 
59
75
  * Start https server using existing certificate
60
- ```
76
+ ```shell
61
77
  $ pywebfs --cert /pathto/host.cert --key /pathto/host.key
62
78
  $ pywebfs -c /pathto/host.cert -k /pathto/host.key
63
79
  ```
64
80
 
65
81
  ## Launch server as a daemon
66
82
 
67
- ```
83
+ ```shell
68
84
  $ pywebexec start
69
85
  $ pywebexec status
70
86
  $ pywebexec stop
@@ -73,8 +89,8 @@ $ pywebexec stop
73
89
 
74
90
  ## Launch command through API
75
91
 
76
- ```
77
- # curl http://myhost:8080/run_script -H 'Content-Type: application/json' -X POST -d '{ "script_name":"myscript", "param":["param1", ...]}
92
+ ```shell
93
+ $ curl http://myhost:8080/run_script -H 'Content-Type: application/json' -X POST -d '{ "script_name":"myscript", "param":["param1", ...]}
78
94
  ```
79
95
 
80
96
  ## API reference
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "Flask>=2.0.3",
15
15
  "Flask-HTTPAuth>=4.8.0",
16
16
  "gunicorn>=21.2.0",
17
+ "ldap3>=2.9.1",
17
18
  ]
18
19
  dynamic=["version"]
19
20
  readme = "README.md"
@@ -1,5 +1,5 @@
1
1
  import sys
2
- from flask import Flask, request, jsonify, render_template
2
+ from flask import Flask, request, jsonify, render_template, session, redirect, url_for
3
3
  from flask_httpauth import HTTPBasicAuth
4
4
  import subprocess
5
5
  import threading
@@ -9,16 +9,33 @@ import uuid
9
9
  import argparse
10
10
  import random
11
11
  import string
12
- from datetime import datetime
12
+ from datetime import datetime, timezone, timedelta
13
13
  import shlex
14
14
  from gunicorn.app.base import Application
15
- from datetime import timezone, timedelta
16
15
  import ipaddress
17
16
  from socket import gethostname, gethostbyname_ex
17
+ import ssl
18
+
19
+ if os.environ.get('PYWEBEXEC_LDAP_SERVER'):
20
+ try:
21
+ from ldap3 import Server, Connection, ALL, SIMPLE, SUBTREE, Tls
22
+ except:
23
+ print("Need to install ldap3: pip install ldap3", file=sys.stderr)
24
+ sys.exit(1)
18
25
 
19
26
  app = Flask(__name__)
27
+ app.secret_key = os.urandom(24) # Secret key for session management
28
+ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Add SameSite attribute to session cookies
20
29
  auth = HTTPBasicAuth()
21
30
 
31
+ app.config['LDAP_SERVER'] = os.environ.get('PYWEBEXEC_LDAP_SERVER')
32
+ app.config['LDAP_USER_ID'] = os.environ.get('PYWEBEXEC_LDAP_USER_ID', "uid")
33
+ app.config['LDAP_GROUPS'] = os.environ.get('PYWEBEXEC_LDAP_GROUPS')
34
+ app.config['LDAP_BASE_DN'] = os.environ.get('PYWEBEXEC_LDAP_BASE_DN')
35
+ app.config['LDAP_BIND_DN'] = os.environ.get('PYWEBEXEC_LDAP_BIND_DN')
36
+ app.config['LDAP_BIND_PASSWORD'] = os.environ.get('PYWEBEXEC_LDAP_BIND_PASSWORD')
37
+ app.config['LDAP_USE_SSL'] = int(os.environ.get('PYWEBEXEC_LDAP_USE_SSL', False))
38
+
22
39
  # Directory to store the command status and output
23
40
  COMMAND_STATUS_DIR = '.web_status'
24
41
  CONFDIR = os.path.expanduser("~/")
@@ -29,6 +46,9 @@ CONFDIR += "/.pywebexec"
29
46
  if not os.path.exists(COMMAND_STATUS_DIR):
30
47
  os.makedirs(COMMAND_STATUS_DIR)
31
48
 
49
+ # In-memory cache for command statuses
50
+ command_status_cache = {}
51
+
32
52
  def generate_random_password(length=12):
33
53
  characters = string.ascii_letters + string.digits + string.punctuation
34
54
  return ''.join(random.choice(characters) for i in range(length))
@@ -124,6 +144,39 @@ class StandaloneApplication(Application):
124
144
  return self.application
125
145
 
126
146
 
147
+ def decode_line(line: bytes) -> str:
148
+ """try decode line exception on binary"""
149
+ try:
150
+ return line.decode()
151
+ except UnicodeDecodeError:
152
+ return ""
153
+
154
+
155
+ def last_line(fd, maxline=1000):
156
+ """last non empty line of file"""
157
+ line = "\n"
158
+ fd.seek(0, os.SEEK_END)
159
+ size = 0
160
+ while line in ["\n", "\r"] and size < maxline:
161
+ try: # catch if file empty / only empty lines
162
+ while fd.read(1) not in [b"\n", b"\r"]:
163
+ fd.seek(-2, os.SEEK_CUR)
164
+ size += 1
165
+ except OSError:
166
+ fd.seek(0)
167
+ line = decode_line(fd.readline())
168
+ break
169
+ line = decode_line(fd.readline())
170
+ fd.seek(-4, os.SEEK_CUR)
171
+ return line.strip()
172
+
173
+
174
+ def get_last_non_empty_line_of_file(file_path):
175
+ """Get the last non-empty line of a file."""
176
+ with open(file_path, 'rb') as f:
177
+ return last_line(f)
178
+
179
+
127
180
  def start_gunicorn(daemon=False, baselog=None):
128
181
  if daemon:
129
182
  errorlog = f"{baselog}.log"
@@ -186,8 +239,8 @@ def daemon_d(action, pidfilepath, hostname=None, args=None):
186
239
  def parseargs():
187
240
  global app, args
188
241
  parser = argparse.ArgumentParser(description='Run the command execution server.')
189
- parser.add_argument('--user', help='Username for basic auth')
190
- parser.add_argument('--password', help='Password for basic auth')
242
+ parser.add_argument('-u', '--user', help='Username for basic auth')
243
+ parser.add_argument('-P', '--password', help='Password for basic auth')
191
244
  parser.add_argument(
192
245
  "-l", "--listen", type=str, default="0.0.0.0", help="HTTP server listen address"
193
246
  )
@@ -241,6 +294,7 @@ def parseargs():
241
294
  else:
242
295
  app.config['USER'] = None
243
296
  app.config['PASSWORD'] = None
297
+
244
298
  return args
245
299
 
246
300
  parseargs()
@@ -267,15 +321,35 @@ def update_command_status(command_id, status, command=None, params=None, start_t
267
321
  status_data['exit_code'] = exit_code
268
322
  if pid is not None:
269
323
  status_data['pid'] = pid
324
+ if status != 'running':
325
+ output_file_path = get_output_file_path(command_id)
326
+ if os.path.exists(output_file_path):
327
+ status_data['last_output_line'] = get_last_non_empty_line_of_file(output_file_path)
270
328
  with open(status_file_path, 'w') as f:
271
329
  json.dump(status_data, f)
330
+
331
+ # Update cache if status is not "running"
332
+ if status != 'running':
333
+ command_status_cache[command_id] = status_data
334
+ elif command_id in command_status_cache:
335
+ del command_status_cache[command_id]
272
336
 
273
337
  def read_command_status(command_id):
338
+ # Return cached status if available
339
+ if command_id in command_status_cache:
340
+ return command_status_cache[command_id]
341
+
274
342
  status_file_path = get_status_file_path(command_id)
275
343
  if not os.path.exists(status_file_path):
276
344
  return None
277
345
  with open(status_file_path, 'r') as f:
278
- return json.load(f)
346
+ status_data = json.load(f)
347
+
348
+ # Cache the status if it is not "running"
349
+ if status_data['status'] != 'running':
350
+ command_status_cache[command_id] = status_data
351
+
352
+ return status_data
279
353
 
280
354
  # Dictionary to store the process objects
281
355
  processes = {}
@@ -307,13 +381,60 @@ def run_command(command, params, command_id):
307
381
  with open(get_output_file_path(command_id), 'a') as output_file:
308
382
  output_file.write(str(e))
309
383
 
310
- def auth_required(f):
311
- if app.config.get('USER'):
312
- return auth.login_required(f)
313
- return f
384
+ @app.before_request
385
+ def check_authentication():
386
+ if 'username' not in session and request.endpoint not in ['login', 'static']:
387
+ return auth.login_required(lambda: None)()
388
+
389
+ @auth.verify_password
390
+ def verify_password(username, password):
391
+ if not username:
392
+ return False
393
+ if app.config['USER']:
394
+ if username == app.config['USER'] and password == app.config['PASSWORD']:
395
+ session['username'] = username
396
+ return True
397
+ elif app.config['LDAP_SERVER']:
398
+ if verify_ldap(username, password):
399
+ session['username'] = username
400
+ return True
401
+ return False
402
+
403
+ def verify_ldap(username, password):
404
+ tls_configuration = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2) if app.config['LDAP_USE_SSL'] else None
405
+ server = Server(app.config['LDAP_SERVER'], use_ssl=app.config['LDAP_USE_SSL'], tls=tls_configuration, get_info=ALL)
406
+ user_filter = f"({app.config['LDAP_USER_ID']}={username})"
407
+ try:
408
+ # Bind with the bind DN and password
409
+ conn = Connection(server, user=app.config['LDAP_BIND_DN'], password=app.config['LDAP_BIND_PASSWORD'], authentication=SIMPLE, auto_bind=True)
410
+ try:
411
+ conn.search(search_base=app.config['LDAP_BASE_DN'], search_filter=user_filter)
412
+ if len(conn.entries) == 0:
413
+ print(f"User {username} not found in LDAP.")
414
+ return False
415
+ user_dn = conn.entries[0].entry_dn
416
+ finally:
417
+ conn.unbind()
418
+
419
+ # Bind with the user DN and password to verify credentials
420
+ conn = Connection(server, user=user_dn, password=password, authentication=SIMPLE, auto_bind=True)
421
+ try:
422
+ if not app.config['LDAP_GROUPS'] and conn.result["result"] == 0:
423
+ return True
424
+ group_filter = "".join([f'(ou={group})' for group in app.config['LDAP_GROUPS'].split(",")])
425
+ group_filter = f"(&{group_filter}(|(member={user_dn})(uniqueMember={user_dn})))"
426
+ conn.search(search_base=app.config['LDAP_BASE_DN'], search_filter=group_filter)
427
+ result = len(conn.entries) > 0
428
+ if not result:
429
+ print(f"User {username} is not a member of groups {app.config['LDAP_GROUPS']}.")
430
+ return result
431
+ finally:
432
+ conn.unbind()
433
+ except Exception as e:
434
+ print(f"LDAP authentication failed: {e}")
435
+ return False
314
436
 
315
437
  @app.route('/run_command', methods=['POST'])
316
- @auth_required
317
438
  def run_command_endpoint():
318
439
  data = request.json
319
440
  command = data.get('command')
@@ -346,7 +467,6 @@ def run_command_endpoint():
346
467
  return jsonify({'message': 'Command is running', 'command_id': command_id})
347
468
 
348
469
  @app.route('/stop_command/<command_id>', methods=['POST'])
349
- @auth_required
350
470
  def stop_command(command_id):
351
471
  status = read_command_status(command_id)
352
472
  if not status or 'pid' not in status:
@@ -370,7 +490,6 @@ def stop_command(command_id):
370
490
  return jsonify({'error': 'Failed to terminate command'}), 500
371
491
 
372
492
  @app.route('/command_status/<command_id>', methods=['GET'])
373
- @auth_required
374
493
  def get_command_status(command_id):
375
494
  status = read_command_status(command_id)
376
495
  if not status:
@@ -385,12 +504,10 @@ def get_command_status(command_id):
385
504
  return jsonify(status)
386
505
 
387
506
  @app.route('/')
388
- @auth_required
389
507
  def index():
390
508
  return render_template('index.html', title=args.title)
391
509
 
392
510
  @app.route('/commands', methods=['GET'])
393
- @auth_required
394
511
  def list_commands():
395
512
  commands = []
396
513
  for filename in os.listdir(COMMAND_STATUS_DIR):
@@ -409,14 +526,14 @@ def list_commands():
409
526
  'start_time': status.get('start_time', 'N/A'),
410
527
  'end_time': status.get('end_time', 'N/A'),
411
528
  'command': command,
412
- 'exit_code': status.get('exit_code', 'N/A')
529
+ 'exit_code': status.get('exit_code', 'N/A'),
530
+ 'last_output_line': status.get('last_output_line', get_last_non_empty_line_of_file(get_output_file_path(command_id))),
413
531
  })
414
532
  # Sort commands by start_time in descending order
415
533
  commands.sort(key=lambda x: x['start_time'], reverse=True)
416
534
  return jsonify(commands)
417
535
 
418
536
  @app.route('/command_output/<command_id>', methods=['GET'])
419
- @auth_required
420
537
  def get_command_output(command_id):
421
538
  output_file_path = get_output_file_path(command_id)
422
539
  if os.path.exists(output_file_path):
@@ -427,15 +544,10 @@ def get_command_output(command_id):
427
544
  return jsonify({'error': 'Invalid command_id'}), 404
428
545
 
429
546
  @app.route('/executables', methods=['GET'])
430
- @auth_required
431
547
  def list_executables():
432
548
  executables = [f for f in os.listdir('.') if os.path.isfile(f) and os.access(f, os.X_OK)]
433
549
  return jsonify(executables)
434
550
 
435
- @auth.verify_password
436
- def verify_password(username, password):
437
- return username == app.config['USER'] and password == app.config['PASSWORD']
438
-
439
551
  def main():
440
552
  basef = f"{CONFDIR}/pywebexec_{args.listen}:{args.port}"
441
553
  if not os.path.exists(CONFDIR):
@@ -448,4 +560,4 @@ def main():
448
560
 
449
561
  if __name__ == '__main__':
450
562
  main()
451
- # app.run(host='0.0.0.0', port=5000)
563
+ # app.run(host='0.0.0.0', port=5000)
@@ -0,0 +1,112 @@
1
+ body { font-family: Arial, sans-serif; }
2
+ .table-container { height: 270px; overflow-y: auto; position: relative; }
3
+ table { width: 100%; border-collapse: collapse; }
4
+ th, td {
5
+ padding: 8px;
6
+ text-align: left;
7
+ border-bottom: 1px solid #ddd;
8
+ white-space: nowrap;
9
+ }
10
+ th { background-color: #f2f2f2; position: sticky; top: 0; z-index: 1; }
11
+ .outcol {
12
+ width: 100%;
13
+ }
14
+ .output {
15
+ white-space: pre-wrap;
16
+ background: #f0f0f0;
17
+ padding: 10px;
18
+ border: 1px solid #ccc;
19
+ font-family: monospace;
20
+ border-radius: 15px;
21
+ overflow-y: auto;
22
+ }
23
+ .copy-icon { cursor: pointer; }
24
+ .monospace { font-family: monospace; }
25
+ .copied { color: green; margin-left: 5px; }
26
+ button {
27
+ -webkit-appearance: none;
28
+ -webkit-border-radius: none;
29
+ appearance: none;
30
+ border-radius: 15px;
31
+ padding: 3px;
32
+ padding-right: 13px;
33
+ border: 1px #555 solid;
34
+ height: 22px;
35
+ font-size: 13px;
36
+ outline: none;
37
+ text-indent: 10px;
38
+ background-color: #eee;
39
+ }
40
+ form {
41
+ padding-bottom: 15px;
42
+ }
43
+ .status-icon {
44
+ display: inline-block;
45
+ width: 16px;
46
+ height: 16px;
47
+ margin-right: 5px;
48
+ background-size: contain;
49
+ background-repeat: no-repeat;
50
+ vertical-align: middle;
51
+ }
52
+ .status-running {
53
+ background-image: url("/static/images/running.svg")
54
+ }
55
+ .status-success {
56
+ background-image: url("/static/images/success.svg")
57
+ }
58
+ .status-failed {
59
+ background-image: url("/static/images/failed.svg")
60
+ }
61
+ .status-aborted {
62
+ background-image: url("/static/images/aborted.svg")
63
+ }
64
+ .copy_clip {
65
+ padding-right: 25px;
66
+ background-repeat: no-repeat;
67
+ background-position: right top;
68
+ background-size: 25px 16px;
69
+ white-space: nowrap;
70
+ }
71
+ .copy_clip:hover {
72
+ cursor: pointer;
73
+ background-image: url("/static/images/copy.svg");
74
+ }
75
+ .copy_clip_ok, .copy_clip_ok:hover {
76
+ background-image: url("/static/images/copy_ok.svg");
77
+ }
78
+ input {
79
+ width: 50%;
80
+ -webkit-appearance: none;
81
+ -webkit-border-radius: none;
82
+ appearance: none;
83
+ border-radius: 15px;
84
+ padding: 3px;
85
+ padding-right: 13px;
86
+ border: 1px #aaa solid;
87
+ height: 15px;
88
+ font-size: 15px;
89
+ outline: none;
90
+ text-indent: 10px;
91
+ background-color: white;
92
+ }
93
+ .currentcommand {
94
+ background-color: #eef;
95
+ }
96
+ .resizer {
97
+ width: 100%;
98
+ height: 5px;
99
+ background: #aaa;
100
+ cursor: ns-resize;
101
+ position: absolute;
102
+ bottom: 0;
103
+ left: 0;
104
+ }
105
+ .resizer-container {
106
+ position: relative;
107
+ height: 5px;
108
+ margin-bottom: 10px;
109
+ }
110
+ tr.clickable-row {
111
+ cursor: pointer;
112
+ }
@@ -0,0 +1 @@
1
+ <svg fill="#000000" viewBox="-1 0 19 19" xmlns="http://www.w3.org/2000/svg" class="cf-icon-svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M16.5 9.5a8 8 0 1 1-8-8 8 8 0 0 1 8 8zm-2.97.006a5.03 5.03 0 1 0-5.03 5.03 5.03 5.03 0 0 0 5.03-5.03zm-7.383-.4H4.289a4.237 4.237 0 0 1 2.565-3.498q.1-.042.2-.079a7.702 7.702 0 0 0-.907 3.577zm0 .8a7.7 7.7 0 0 0 .908 3.577q-.102-.037-.201-.079a4.225 4.225 0 0 1-2.565-3.498zm.8-.8a9.04 9.04 0 0 1 .163-1.402 6.164 6.164 0 0 1 .445-1.415c.289-.615.66-1.013.945-1.013.285 0 .656.398.945 1.013a6.18 6.18 0 0 1 .445 1.415 9.078 9.078 0 0 1 .163 1.402zm3.106.8a9.073 9.073 0 0 1-.163 1.402 6.187 6.187 0 0 1-.445 1.415c-.289.616-.66 1.013-.945 1.013-.285 0-.656-.397-.945-1.013a6.172 6.172 0 0 1-.445-1.415 9.036 9.036 0 0 1-.163-1.402zm1.438-3.391a4.211 4.211 0 0 1 1.22 2.591h-1.858a7.698 7.698 0 0 0-.908-3.577q.102.037.201.08a4.208 4.208 0 0 1 1.345.906zm-.638 3.391h1.858a4.238 4.238 0 0 1-2.565 3.498q-.1.043-.2.08a7.697 7.697 0 0 0 .907-3.578z"></path></g></svg>