pywebexec 0.1.0__tar.gz → 1.0.0__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 (25) hide show
  1. {pywebexec-0.1.0/pywebexec.egg-info → pywebexec-1.0.0}/PKG-INFO +23 -5
  2. {pywebexec-0.1.0 → pywebexec-1.0.0}/README.md +22 -4
  3. pywebexec-1.0.0/pywebexec/pywebexec.py +451 -0
  4. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/templates/index.html +57 -57
  5. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/version.py +2 -2
  6. {pywebexec-0.1.0 → pywebexec-1.0.0/pywebexec.egg-info}/PKG-INFO +23 -5
  7. pywebexec-0.1.0/pywebexec/pywebexec.py +0 -360
  8. {pywebexec-0.1.0 → pywebexec-1.0.0}/.github/workflows/python-publish.yml +0 -0
  9. {pywebexec-0.1.0 → pywebexec-1.0.0}/.gitignore +0 -0
  10. {pywebexec-0.1.0 → pywebexec-1.0.0}/LICENSE +0 -0
  11. {pywebexec-0.1.0 → pywebexec-1.0.0}/pyproject.toml +0 -0
  12. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/__init__.py +0 -0
  13. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/aborted.svg +0 -0
  14. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/copy.svg +0 -0
  15. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/copy_ok.svg +0 -0
  16. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/failed.svg +0 -0
  17. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/running.svg +0 -0
  18. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/success.svg +0 -0
  19. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/templates/__init__.py +0 -0
  20. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec.egg-info/SOURCES.txt +0 -0
  21. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec.egg-info/dependency_links.txt +0 -0
  22. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec.egg-info/entry_points.txt +0 -0
  23. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec.egg-info/requires.txt +0 -0
  24. {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec.egg-info/top_level.txt +0 -0
  25. {pywebexec-0.1.0 → pywebexec-1.0.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: pywebexec
3
- Version: 0.1.0
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 current directory
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 (Linux)
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 |
@@ -24,7 +24,7 @@ $ pywebexec
24
24
 
25
25
  ## features
26
26
 
27
- * Serve executables in current directory
27
+ * Serve executables in a directory
28
28
  * Launch commands with params from web browser or API call
29
29
  * Follow live output
30
30
  * Stop command
@@ -33,11 +33,12 @@ $ pywebexec
33
33
  * HTTPS self-signed certificate generator
34
34
  * Can be started as a daemon (POSIX)
35
35
  * uses gunicorn to serve http/https
36
+ * compatible Linux/MacOS
36
37
 
37
38
  ## Customize server
38
39
  ```
39
- $ pywebexec --listen 0.0.0.0 --port 8080
40
- $ pywebexec -l 0.0.0.0 -p 8080
40
+ $ pywebexec --dir ~/myscripts --listen 0.0.0.0 --port 8080
41
+ $ pywebexec -d ~/myscripts -l 0.0.0.0 -p 8080
41
42
  ```
42
43
 
43
44
  ## Basic auth user/password
@@ -61,7 +62,7 @@ $ pywebfs --cert /pathto/host.cert --key /pathto/host.key
61
62
  $ pywebfs -c /pathto/host.cert -k /pathto/host.key
62
63
  ```
63
64
 
64
- ## Launch server as a daemon (Linux)
65
+ ## Launch server as a daemon
65
66
 
66
67
  ```
67
68
  $ pywebexec start
@@ -70,3 +71,20 @@ $ pywebexec stop
70
71
  ```
71
72
  * log of server are stored in directory `[.config/].pywebexec/pywebexec_<listen>:<port>.log`
72
73
 
74
+ ## Launch command through API
75
+
76
+ ```
77
+ # curl http://myhost:8080/run_script -H 'Content-Type: application/json' -X POST -d '{ "script_name":"myscript", "param":["param1", ...]}
78
+ ```
79
+
80
+ ## API reference
81
+
82
+
83
+ | method | route | params/payload | returns
84
+ |-----------|-----------------------------|--------------------|---------------------|
85
+ | POST | /run_command | command: str<br>params: array[str] | command_id: uuid<br>message: str |
86
+ | POST | /stop_command/command_id | | message: str |
87
+ | 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 |
88
+ | GET | /command_output/command_id | | output: str<br>status: str |
89
+ | 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 |
90
+ | GET | /executables | | array of str |
@@ -0,0 +1,451 @@
1
+ import sys
2
+ from flask import Flask, request, jsonify, render_template
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
13
+ import shlex
14
+ from gunicorn.app.base import Application
15
+ from datetime import timezone, timedelta
16
+ import ipaddress
17
+ from socket import gethostname, gethostbyname_ex
18
+
19
+ app = Flask(__name__)
20
+ auth = HTTPBasicAuth()
21
+
22
+ # Directory to store the command status and output
23
+ COMMAND_STATUS_DIR = '.web_status'
24
+ CONFDIR = os.path.expanduser("~/")
25
+ if os.path.isdir(f"{CONFDIR}/.config"):
26
+ CONFDIR += '/.config'
27
+ CONFDIR += "/.pywebexec"
28
+
29
+ if not os.path.exists(COMMAND_STATUS_DIR):
30
+ os.makedirs(COMMAND_STATUS_DIR)
31
+
32
+ def generate_random_password(length=12):
33
+ characters = string.ascii_letters + string.digits + string.punctuation
34
+ return ''.join(random.choice(characters) for i in range(length))
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
+
108
+ class StandaloneApplication(Application):
109
+
110
+ def __init__(self, app, options=None):
111
+ self.options = options or {}
112
+ self.application = app
113
+ super().__init__()
114
+
115
+ def load_config(self):
116
+ config = {
117
+ key: value for key, value in self.options.items()
118
+ if key in self.cfg.settings and value is not None
119
+ }
120
+ for key, value in config.items():
121
+ self.cfg.set(key.lower(), value)
122
+
123
+ def load(self):
124
+ return self.application
125
+
126
+
127
+ def start_gunicorn(daemon=False, baselog=None):
128
+ if daemon:
129
+ errorlog = f"{baselog}.log"
130
+ accesslog = None # f"{baselog}.access.log"
131
+ pidfile = f"{baselog}.pid"
132
+ else:
133
+ errorlog = "-"
134
+ accesslog = "-"
135
+ pidfile = None
136
+ options = {
137
+ 'bind': '%s:%s' % (args.listen, args.port),
138
+ 'workers': 4,
139
+ 'timeout': 600,
140
+ 'certfile': args.cert,
141
+ 'keyfile': args.key,
142
+ 'daemon': daemon,
143
+ 'errorlog': errorlog,
144
+ 'accesslog': accesslog,
145
+ 'pidfile': pidfile,
146
+ }
147
+ StandaloneApplication(app, options=options).run()
148
+
149
+ def daemon_d(action, pidfilepath, hostname=None, args=None):
150
+ """start/stop daemon"""
151
+ import signal
152
+ import daemon, daemon.pidfile
153
+
154
+ pidfile = daemon.pidfile.TimeoutPIDLockFile(pidfilepath+".pid", acquire_timeout=30)
155
+ if action == "stop":
156
+ if pidfile.is_locked():
157
+ pid = pidfile.read_pid()
158
+ print(f"Stopping server pid {pid}")
159
+ try:
160
+ os.kill(pid, signal.SIGINT)
161
+ except:
162
+ return False
163
+ return True
164
+ elif action == "status":
165
+ status = pidfile.is_locked()
166
+ if status:
167
+ print(f"pywebexec running pid {pidfile.read_pid()}")
168
+ return True
169
+ print("pywebexec not running")
170
+ return False
171
+ elif action == "start":
172
+ print(f"Starting server")
173
+ log = open(pidfilepath + ".log", "ab+")
174
+ daemon_context = daemon.DaemonContext(
175
+ stderr=log,
176
+ pidfile=pidfile,
177
+ umask=0o077,
178
+ working_directory=os.getcwd(),
179
+ )
180
+ with daemon_context:
181
+ try:
182
+ start_gunicorn()
183
+ except Exception as e:
184
+ print(e)
185
+
186
+ def parseargs():
187
+ global app, args
188
+ 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')
191
+ parser.add_argument(
192
+ "-l", "--listen", type=str, default="0.0.0.0", help="HTTP server listen address"
193
+ )
194
+ parser.add_argument(
195
+ "-p", "--port", type=int, default=8080, help="HTTP server listen port"
196
+ )
197
+ parser.add_argument(
198
+ "-d", "--dir", type=str, default=os.getcwd(), help="Serve target directory"
199
+ )
200
+ parser.add_argument(
201
+ "-t",
202
+ "--title",
203
+ type=str,
204
+ default="pywebexec",
205
+ help="Web html title",
206
+ )
207
+ parser.add_argument("-c", "--cert", type=str, help="Path to https certificate")
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")
210
+ parser.add_argument("action", nargs="?", help="daemon action start/stop/restart/status", choices=["start","stop","restart","status"])
211
+
212
+ args = parser.parse_args()
213
+ if os.path.isdir(args.dir):
214
+ try:
215
+ os.chdir(args.dir)
216
+ except OSError:
217
+ print(f"Error: cannot chdir {args.dir}", file=sys.stderr)
218
+ sys.exit(1)
219
+ else:
220
+ print(f"Error: {args.dir} not found", file=sys.stderr)
221
+ sys.exit(1)
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
+
234
+ if args.user:
235
+ app.config['USER'] = args.user
236
+ if args.password:
237
+ app.config['PASSWORD'] = args.password
238
+ else:
239
+ app.config['PASSWORD'] = generate_random_password()
240
+ print(f'Generated password for user {args.user}: {app.config["PASSWORD"]}')
241
+ else:
242
+ app.config['USER'] = None
243
+ app.config['PASSWORD'] = None
244
+ return args
245
+
246
+ parseargs()
247
+
248
+ def get_status_file_path(command_id):
249
+ return os.path.join(COMMAND_STATUS_DIR, f'{command_id}.json')
250
+
251
+ def get_output_file_path(command_id):
252
+ return os.path.join(COMMAND_STATUS_DIR, f'{command_id}_output.txt')
253
+
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 {}
257
+ status_data['status'] = status
258
+ if command is not None:
259
+ status_data['command'] = command
260
+ if params is not None:
261
+ status_data['params'] = params
262
+ if start_time is not None:
263
+ status_data['start_time'] = start_time
264
+ if end_time is not None:
265
+ status_data['end_time'] = end_time
266
+ if exit_code is not None:
267
+ status_data['exit_code'] = exit_code
268
+ if pid is not None:
269
+ status_data['pid'] = pid
270
+ with open(status_file_path, 'w') as f:
271
+ json.dump(status_data, f)
272
+
273
+ def read_command_status(command_id):
274
+ status_file_path = get_status_file_path(command_id)
275
+ if not os.path.exists(status_file_path):
276
+ return None
277
+ with open(status_file_path, 'r') as f:
278
+ return json.load(f)
279
+
280
+ # Dictionary to store the process objects
281
+ processes = {}
282
+
283
+ def run_command(command, params, command_id):
284
+ start_time = datetime.now().isoformat()
285
+ update_command_status(command_id, 'running', command=command, params=params, start_time=start_time)
286
+ try:
287
+ output_file_path = get_output_file_path(command_id)
288
+ with open(output_file_path, 'w') as output_file:
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
293
+ process.wait()
294
+ processes.pop(command_id, None)
295
+
296
+ end_time = datetime.now().isoformat()
297
+ # Update the status based on the result
298
+ if process.returncode == 0:
299
+ update_command_status(command_id, 'success', end_time=end_time, exit_code=process.returncode)
300
+ elif process.returncode == -15:
301
+ update_command_status(command_id, 'aborted', end_time=end_time, exit_code=process.returncode)
302
+ else:
303
+ update_command_status(command_id, 'failed', end_time=end_time, exit_code=process.returncode)
304
+ except Exception as e:
305
+ end_time = datetime.now().isoformat()
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:
308
+ output_file.write(str(e))
309
+
310
+ def auth_required(f):
311
+ if app.config.get('USER'):
312
+ return auth.login_required(f)
313
+ return f
314
+
315
+ @app.route('/run_command', methods=['POST'])
316
+ @auth_required
317
+ def run_command_endpoint():
318
+ data = request.json
319
+ command = data.get('command')
320
+ params = data.get('params', [])
321
+
322
+ if not command:
323
+ return jsonify({'error': 'command is required'}), 400
324
+
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
329
+
330
+ # Split params using shell-like syntax
331
+ try:
332
+ params = shlex.split(' '.join(params))
333
+ except ValueError as e:
334
+ return jsonify({'error': str(e)}), 400
335
+
336
+ # Generate a unique command_id
337
+ command_id = str(uuid.uuid4())
338
+
339
+ # Set the initial status to running and save command details
340
+ update_command_status(command_id, 'running', command, params)
341
+
342
+ # Run the command in a separate thread
343
+ thread = threading.Thread(target=run_command, args=(command_path, params, command_id))
344
+ thread.start()
345
+
346
+ return jsonify({'message': 'Command is running', 'command_id': command_id})
347
+
348
+ @app.route('/stop_command/<command_id>', methods=['POST'])
349
+ @auth_required
350
+ def stop_command(command_id):
351
+ status = read_command_status(command_id)
352
+ if not status or 'pid' not in status:
353
+ return jsonify({'error': 'Invalid command_id or command not running'}), 400
354
+
355
+ pid = status['pid']
356
+ end_time = datetime.now().isoformat()
357
+ try:
358
+ os.kill(pid, 15) # Send SIGTERM
359
+ update_command_status(command_id, 'aborted', end_time=end_time, exit_code=-15)
360
+ return jsonify({'message': 'Command aborted'})
361
+ except Exception as e:
362
+ status_data = read_command_status(command_id) or {}
363
+ status_data['status'] = 'failed'
364
+ status_data['end_time'] = end_time
365
+ status_data['exit_code'] = 1
366
+ with open(get_status_file_path(command_id), 'w') as f:
367
+ json.dump(status_data, f)
368
+ with open(get_output_file_path(command_id), 'a') as output_file:
369
+ output_file.write(str(e))
370
+ return jsonify({'error': 'Failed to terminate command'}), 500
371
+
372
+ @app.route('/command_status/<command_id>', methods=['GET'])
373
+ @auth_required
374
+ def get_command_status(command_id):
375
+ status = read_command_status(command_id)
376
+ if not status:
377
+ return jsonify({'error': 'Invalid command_id'}), 404
378
+
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
384
+
385
+ return jsonify(status)
386
+
387
+ @app.route('/')
388
+ @auth_required
389
+ def index():
390
+ return render_template('index.html', title=args.title)
391
+
392
+ @app.route('/commands', methods=['GET'])
393
+ @auth_required
394
+ def list_commands():
395
+ commands = []
396
+ for filename in os.listdir(COMMAND_STATUS_DIR):
397
+ if filename.endswith('.json'):
398
+ command_id = filename[:-5]
399
+ status = read_command_status(command_id)
400
+ if status:
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,
408
+ 'status': status['status'],
409
+ 'start_time': status.get('start_time', 'N/A'),
410
+ 'end_time': status.get('end_time', 'N/A'),
411
+ 'command': command,
412
+ 'exit_code': status.get('exit_code', 'N/A')
413
+ })
414
+ # Sort commands by start_time in descending order
415
+ commands.sort(key=lambda x: x['start_time'], reverse=True)
416
+ return jsonify(commands)
417
+
418
+ @app.route('/command_output/<command_id>', methods=['GET'])
419
+ @auth_required
420
+ def get_command_output(command_id):
421
+ output_file_path = get_output_file_path(command_id)
422
+ if os.path.exists(output_file_path):
423
+ with open(output_file_path, 'r') as output_file:
424
+ output = output_file.read()
425
+ status_data = read_command_status(command_id) or {}
426
+ return jsonify({'output': output, 'status': status_data.get("status")})
427
+ return jsonify({'error': 'Invalid command_id'}), 404
428
+
429
+ @app.route('/executables', methods=['GET'])
430
+ @auth_required
431
+ def list_executables():
432
+ executables = [f for f in os.listdir('.') if os.path.isfile(f) and os.access(f, os.X_OK)]
433
+ return jsonify(executables)
434
+
435
+ @auth.verify_password
436
+ def verify_password(username, password):
437
+ return username == app.config['USER'] and password == app.config['PASSWORD']
438
+
439
+ def main():
440
+ basef = f"{CONFDIR}/pywebexec_{args.listen}:{args.port}"
441
+ if not os.path.exists(CONFDIR):
442
+ os.mkdir(CONFDIR, mode=0o700)
443
+ if args.action == "start":
444
+ return start_gunicorn(daemon=True, baselog=basef)
445
+ if args.action:
446
+ return daemon_d(args.action, pidfilepath=basef)
447
+ return start_gunicorn()
448
+
449
+ if __name__ == '__main__':
450
+ main()
451
+ # app.run(host='0.0.0.0', port=5000)
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <title>pywebexec</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
- .currentscript {
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>pywebexec</h1>
106
+ <h1>{{ title }}</h1>
107
107
  <form id="launchForm">
108
- <label for="scriptName">Command:</label>
109
- <select id="scriptName" name="scriptName"></select>
110
- <label for="params">Params:</label>
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>Script ID</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="scripts"></tbody>
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 currentScriptId = null;
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 scriptName = document.getElementById('scriptName').value;
141
+ const commandName = document.getElementById('commandName').value;
142
142
  const params = document.getElementById('params').value.split(' ');
143
- const response = await fetch('/run_script', {
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({ script_name: scriptName, params: params })
148
+ body: JSON.stringify({ command: commandName, params: params })
149
149
  });
150
150
  const data = await response.json();
151
- fetchScripts();
152
- viewOutput(data.script_id);
151
+ fetchCommands();
152
+ viewOutput(data.command_id);
153
153
  });
154
154
 
155
- async function fetchScripts() {
156
- const response = await fetch('/scripts');
157
- const scripts = await response.json();
158
- scripts.sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
159
- const scriptsTbody = document.getElementById('scripts');
160
- scriptsTbody.innerHTML = '';
161
- scripts.forEach(script => {
162
- const scriptRow = document.createElement('tr');
163
- scriptRow.className = script.script_id === currentScriptId ? 'currentscript' : '';
164
- scriptRow.innerHTML = `
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('${script.script_id}', this)">${script.script_id.slice(0, 8)}</span>
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-${script.status}"></span>${script.status}</td>
169
- <td>${formatTime(script.start_time)}</td>
170
- <td>${script.status === 'running' ? formatDuration(script.start_time, new Date().toISOString()) : formatDuration(script.start_time, script.end_time)}</td>
171
- <td>${script.exit_code}</td>
172
- <td>${script.command.replace(/^\.\//, '')}</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('${script.script_id}')">Log</button>
175
- <button onclick="relaunchScript('${script.script_id}')">Relaunch</button>
176
- ${script.status === 'running' ? `<button onclick="stopScript('${script.script_id}')">Stop</button>` : ''}
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
- scriptsTbody.appendChild(scriptRow);
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 scriptNameSelect = document.getElementById('scriptName');
187
- scriptNameSelect.innerHTML = '';
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
- scriptNameSelect.appendChild(option);
192
+ commandNameSelect.appendChild(option);
193
193
  });
194
194
  }
195
195
 
196
- async function fetchOutput(script_id) {
196
+ async function fetchOutput(command_id) {
197
197
  const outputDiv = document.getElementById('output');
198
- const response = await fetch(`/script_output/${script_id}`);
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(script_id) {
212
+ async function viewOutput(command_id) {
213
213
  adjustOutputHeight();
214
- currentScriptId = script_id;
214
+ currentCommandId = command_id;
215
215
  clearInterval(outputInterval);
216
- const response = await fetch(`/script_status/${script_id}`);
216
+ const response = await fetch(`/command_status/${command_id}`);
217
217
  const data = await response.json();
218
218
  if (data.status === 'running') {
219
- fetchOutput(script_id);
220
- outputInterval = setInterval(() => fetchOutput(script_id), 1000);
219
+ fetchOutput(command_id);
220
+ outputInterval = setInterval(() => fetchOutput(command_id), 1000);
221
221
  } else {
222
- fetchOutput(script_id);
222
+ fetchOutput(command_id);
223
223
  }
224
- fetchScripts(); // Refresh the script list to highlight the current script
224
+ fetchCommands(); // Refresh the command list to highlight the current command
225
225
  }
226
226
 
227
- async function relaunchScript(script_id) {
228
- const response = await fetch(`/script_status/${script_id}`);
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('/run_script', {
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
- script_name: data.script_name,
240
+ command: data.command,
241
241
  params: data.params
242
242
  })
243
243
  });
244
244
  const relaunchData = await relaunchResponse.json();
245
- fetchScripts();
246
- viewOutput(relaunchData.script_id);
245
+ fetchCommands();
246
+ viewOutput(relaunchData.command_id);
247
247
  }
248
248
 
249
- async function stopScript(script_id) {
250
- const response = await fetch(`/stop_script/${script_id}`, {
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
- fetchScripts();
258
+ fetchCommands();
259
259
  }
260
260
  }
261
261
 
@@ -322,9 +322,9 @@
322
322
  initResizer();
323
323
  });
324
324
 
325
- fetchScripts();
325
+ fetchCommands();
326
326
  fetchExecutables();
327
- setInterval(fetchScripts, 5000);
327
+ setInterval(fetchCommands, 5000);
328
328
  </script>
329
329
  </body>
330
330
  </html>
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.1.0'
16
- __version_tuple__ = version_tuple = (0, 1, 0)
15
+ __version__ = version = '1.0.0'
16
+ __version_tuple__ = version_tuple = (1, 0, 0)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: pywebexec
3
- Version: 0.1.0
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 current directory
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 (Linux)
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,360 +0,0 @@
1
- import sys
2
- from flask import Flask, request, jsonify, render_template
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
13
- import shlex
14
- from gunicorn.app.base import BaseApplication, Application
15
-
16
- app = Flask(__name__)
17
- auth = HTTPBasicAuth()
18
-
19
- # Directory to store the script status and output
20
- SCRIPT_STATUS_DIR = '.web_status'
21
- CONFDIR = os.path.expanduser("~/")
22
- if os.path.isdir(f"{CONFDIR}/.config"):
23
- CONFDIR += '/.config'
24
- CONFDIR += "/.pywebexec"
25
-
26
- if not os.path.exists(SCRIPT_STATUS_DIR):
27
- os.makedirs(SCRIPT_STATUS_DIR)
28
-
29
- def generate_random_password(length=12):
30
- characters = string.ascii_letters + string.digits + string.punctuation
31
- return ''.join(random.choice(characters) for i in range(length))
32
-
33
- class StandaloneApplication(Application):
34
-
35
- def __init__(self, app, options=None):
36
- self.options = options or {}
37
- self.application = app
38
- super().__init__()
39
-
40
- def load_config(self):
41
- config = {
42
- key: value for key, value in self.options.items()
43
- if key in self.cfg.settings and value is not None
44
- }
45
- for key, value in config.items():
46
- self.cfg.set(key.lower(), value)
47
-
48
- def load(self):
49
- return self.application
50
-
51
-
52
- def start_gunicorn(daemon=False, baselog=None):
53
- if daemon:
54
- errorlog = f"{baselog}.log"
55
- accesslog = None # f"{baselog}.access.log"
56
- pidfile = f"{baselog}.pid"
57
- else:
58
- errorlog = "-"
59
- accesslog = "-"
60
- pidfile = None
61
- options = {
62
- 'bind': '%s:%s' % (args.listen, args.port),
63
- 'workers': 4,
64
- 'timeout': 600,
65
- 'certfile': args.cert,
66
- 'keyfile': args.key,
67
- 'daemon': daemon,
68
- 'errorlog': errorlog,
69
- 'accesslog': accesslog,
70
- 'pidfile': pidfile,
71
- }
72
- StandaloneApplication(app, options=options).run()
73
-
74
- def daemon_d(action, pidfilepath, hostname=None, args=None):
75
- """start/stop daemon"""
76
- import signal
77
- import daemon, daemon.pidfile
78
-
79
- pidfile = daemon.pidfile.TimeoutPIDLockFile(pidfilepath+".pid", acquire_timeout=30)
80
- if action == "stop":
81
- if pidfile.is_locked():
82
- pid = pidfile.read_pid()
83
- print(f"Stopping server pid {pid}")
84
- try:
85
- os.kill(pid, signal.SIGINT)
86
- except:
87
- return False
88
- return True
89
- elif action == "status":
90
- status = pidfile.is_locked()
91
- if status:
92
- print(f"pywebexec running pid {pidfile.read_pid()}")
93
- return True
94
- print("pywebexec not running")
95
- return False
96
- elif action == "start":
97
- print(f"Starting server")
98
- log = open(pidfilepath + ".log", "ab+")
99
- daemon_context = daemon.DaemonContext(
100
- stderr=log,
101
- pidfile=pidfile,
102
- umask=0o077,
103
- working_directory=os.getcwd(),
104
- )
105
- with daemon_context:
106
- try:
107
- start_gunicorn()
108
- except Exception as e:
109
- print(e)
110
-
111
- def parseargs():
112
- global app, args
113
- parser = argparse.ArgumentParser(description='Run the script execution server.')
114
- parser.add_argument('--user', help='Username for basic auth')
115
- parser.add_argument('--password', help='Password for basic auth')
116
- parser.add_argument(
117
- "-l", "--listen", type=str, default="0.0.0.0", help="HTTP server listen address"
118
- )
119
- parser.add_argument(
120
- "-p", "--port", type=int, default=8080, help="HTTP server listen port"
121
- )
122
- parser.add_argument(
123
- "-d", "--dir", type=str, default=os.getcwd(), help="Serve target directory"
124
- )
125
- parser.add_argument(
126
- "-t",
127
- "--title",
128
- type=str,
129
- default="FileBrowser",
130
- help="Web html title",
131
- )
132
- parser.add_argument("-c", "--cert", type=str, help="Path to https certificate")
133
- parser.add_argument("-k", "--key", type=str, help="Path to https certificate key")
134
- parser.add_argument("action", nargs="?", help="daemon action start/stop/restart/status", choices=["start","stop","restart","status"])
135
-
136
- args = parser.parse_args()
137
- if os.path.isdir(args.dir):
138
- try:
139
- os.chdir(args.dir)
140
- except OSError:
141
- print(f"Error: cannot chdir {args.dir}", file=sys.stderr)
142
- sys.exit(1)
143
- else:
144
- print(f"Error: {args.dir} not found", file=sys.stderr)
145
- sys.exit(1)
146
-
147
- if args.user:
148
- app.config['USER'] = args.user
149
- if args.password:
150
- app.config['PASSWORD'] = args.password
151
- else:
152
- app.config['PASSWORD'] = generate_random_password()
153
- print(f'Generated password for user {args.user}: {app.config["PASSWORD"]}')
154
- else:
155
- app.config['USER'] = None
156
- app.config['PASSWORD'] = None
157
- return args
158
-
159
- parseargs()
160
-
161
- def get_status_file_path(script_id):
162
- return os.path.join(SCRIPT_STATUS_DIR, f'{script_id}.json')
163
-
164
- def get_output_file_path(script_id):
165
- return os.path.join(SCRIPT_STATUS_DIR, f'{script_id}_output.txt')
166
-
167
- def update_script_status(script_id, status, script_name=None, params=None, start_time=None, end_time=None, exit_code=None, pid=None):
168
- status_file_path = get_status_file_path(script_id)
169
- status_data = read_script_status(script_id) or {}
170
- status_data['status'] = status
171
- if script_name is not None:
172
- status_data['script_name'] = script_name
173
- if params is not None:
174
- status_data['params'] = params
175
- if start_time is not None:
176
- status_data['start_time'] = start_time
177
- if end_time is not None:
178
- status_data['end_time'] = end_time
179
- if exit_code is not None:
180
- status_data['exit_code'] = exit_code
181
- if pid is not None:
182
- status_data['pid'] = pid
183
- with open(status_file_path, 'w') as f:
184
- json.dump(status_data, f)
185
-
186
- def read_script_status(script_id):
187
- status_file_path = get_status_file_path(script_id)
188
- if not os.path.exists(status_file_path):
189
- return None
190
- with open(status_file_path, 'r') as f:
191
- return json.load(f)
192
-
193
- # Dictionary to store the process objects
194
- processes = {}
195
-
196
- def run_script(script_name, params, script_id):
197
- start_time = datetime.now().isoformat()
198
- update_script_status(script_id, 'running', script_name=script_name, params=params, start_time=start_time)
199
- try:
200
- output_file_path = get_output_file_path(script_id)
201
- with open(output_file_path, 'w') as output_file:
202
- # Run the script with parameters and redirect stdout and stderr to the file
203
- process = subprocess.Popen([script_name] + params, stdout=output_file, stderr=output_file, bufsize=0) #text=True)
204
- update_script_status(script_id, 'running', pid=process.pid)
205
- processes[script_id] = process
206
- process.wait()
207
- processes.pop(script_id, None)
208
-
209
- end_time = datetime.now().isoformat()
210
- # Update the status based on the result
211
- if process.returncode == 0:
212
- update_script_status(script_id, 'success', end_time=end_time, exit_code=process.returncode)
213
- elif process.returncode == -15:
214
- update_script_status(script_id, 'aborted', end_time=end_time, exit_code=process.returncode)
215
- else:
216
- update_script_status(script_id, 'failed', end_time=end_time, exit_code=process.returncode)
217
- except Exception as e:
218
- end_time = datetime.now().isoformat()
219
- update_script_status(script_id, 'failed', end_time=end_time, exit_code=1)
220
- with open(get_output_file_path(script_id), 'a') as output_file:
221
- output_file.write(str(e))
222
-
223
- def auth_required(f):
224
- if app.config.get('USER'):
225
- return auth.login_required(f)
226
- return f
227
-
228
- @app.route('/run_script', methods=['POST'])
229
- @auth_required
230
- def run_script_endpoint():
231
- data = request.json
232
- script_name = data.get('script_name')
233
- params = data.get('params', [])
234
-
235
- if not script_name:
236
- return jsonify({'error': 'script_name is required'}), 400
237
-
238
- # Ensure the script is an executable in the current directory
239
- script_path = os.path.join(".", os.path.basename(script_name))
240
- if not os.path.isfile(script_path) or not os.access(script_path, os.X_OK):
241
- return jsonify({'error': 'script_name must be an executable in the current directory'}), 400
242
-
243
- # Split params using shell-like syntax
244
- try:
245
- params = shlex.split(' '.join(params))
246
- except ValueError as e:
247
- return jsonify({'error': str(e)}), 400
248
-
249
- # Generate a unique script_id
250
- script_id = str(uuid.uuid4())
251
-
252
- # Set the initial status to running and save script details
253
- update_script_status(script_id, 'running', script_name, params)
254
-
255
- # Run the script in a separate thread
256
- thread = threading.Thread(target=run_script, args=(script_path, params, script_id))
257
- thread.start()
258
-
259
- return jsonify({'message': 'Script is running', 'script_id': script_id})
260
-
261
- @app.route('/stop_script/<script_id>', methods=['POST'])
262
- @auth_required
263
- def stop_script(script_id):
264
- status = read_script_status(script_id)
265
- if not status or 'pid' not in status:
266
- return jsonify({'error': 'Invalid script_id or script not running'}), 400
267
-
268
- pid = status['pid']
269
- end_time = datetime.now().isoformat()
270
- try:
271
- os.kill(pid, 15) # Send SIGTERM
272
- update_script_status(script_id, 'aborted', end_time=end_time, exit_code=-15)
273
- return jsonify({'message': 'Script aborted'})
274
- except Exception as e:
275
- status_data = read_script_status(script_id) or {}
276
- status_data['status'] = 'failed'
277
- status_data['end_time'] = end_time
278
- status_data['exit_code'] = 1
279
- with open(get_status_file_path(script_id), 'w') as f:
280
- json.dump(status_data, f)
281
- with open(get_output_file_path(script_id), 'a') as output_file:
282
- output_file.write(str(e))
283
- return jsonify({'error': 'Failed to terminate script'}), 500
284
-
285
- @app.route('/script_status/<script_id>', methods=['GET'])
286
- @auth_required
287
- def get_script_status(script_id):
288
- status = read_script_status(script_id)
289
- if not status:
290
- return jsonify({'error': 'Invalid script_id'}), 404
291
-
292
- output_file_path = get_output_file_path(script_id)
293
- if os.path.exists(output_file_path):
294
- with open(output_file_path, 'r') as output_file:
295
- output = output_file.read()
296
- status['output'] = output
297
-
298
- return jsonify(status)
299
-
300
- @app.route('/')
301
- @auth_required
302
- def index():
303
- return render_template('index.html')
304
-
305
- @app.route('/scripts', methods=['GET'])
306
- @auth_required
307
- def list_scripts():
308
- scripts = []
309
- for filename in os.listdir(SCRIPT_STATUS_DIR):
310
- if filename.endswith('.json'):
311
- script_id = filename[:-5]
312
- status = read_script_status(script_id)
313
- if status:
314
- command = status['script_name'] + ' ' + shlex.join(status['params'])
315
- scripts.append({
316
- 'script_id': script_id,
317
- 'status': status['status'],
318
- 'start_time': status.get('start_time', 'N/A'),
319
- 'end_time': status.get('end_time', 'N/A'),
320
- 'command': command,
321
- 'exit_code': status.get('exit_code', 'N/A')
322
- })
323
- # Sort scripts by start_time in descending order
324
- scripts.sort(key=lambda x: x['start_time'], reverse=True)
325
- return jsonify(scripts)
326
-
327
- @app.route('/script_output/<script_id>', methods=['GET'])
328
- @auth_required
329
- def get_script_output(script_id):
330
- output_file_path = get_output_file_path(script_id)
331
- if os.path.exists(output_file_path):
332
- with open(output_file_path, 'r') as output_file:
333
- output = output_file.read()
334
- status_data = read_script_status(script_id) or {}
335
- return jsonify({'output': output, 'status': status_data.get("status")})
336
- return jsonify({'error': 'Invalid script_id'}), 404
337
-
338
- @app.route('/executables', methods=['GET'])
339
- @auth_required
340
- def list_executables():
341
- executables = [f for f in os.listdir('.') if os.path.isfile(f) and os.access(f, os.X_OK)]
342
- return jsonify(executables)
343
-
344
- @auth.verify_password
345
- def verify_password(username, password):
346
- return username == app.config['USER'] and password == app.config['PASSWORD']
347
-
348
- def main():
349
- basef = f"{CONFDIR}/pywebexec_{args.listen}:{args.port}"
350
- if not os.path.exists(CONFDIR):
351
- os.mkdir(CONFDIR, mode=0o700)
352
- if args.action == "start":
353
- return start_gunicorn(daemon=True, baselog=basef)
354
- if args.action:
355
- return daemon_d(args.action, pidfilepath=basef)
356
- return start_gunicorn()
357
-
358
- if __name__ == '__main__':
359
- main()
360
- # app.run(host='0.0.0.0', port=5000)
File without changes
File without changes
File without changes
File without changes