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.
- {pywebexec-0.1.0/pywebexec.egg-info → pywebexec-1.0.0}/PKG-INFO +23 -5
- {pywebexec-0.1.0 → pywebexec-1.0.0}/README.md +22 -4
- pywebexec-1.0.0/pywebexec/pywebexec.py +451 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/templates/index.html +57 -57
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/version.py +2 -2
- {pywebexec-0.1.0 → pywebexec-1.0.0/pywebexec.egg-info}/PKG-INFO +23 -5
- pywebexec-0.1.0/pywebexec/pywebexec.py +0 -360
- {pywebexec-0.1.0 → pywebexec-1.0.0}/.github/workflows/python-publish.yml +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/.gitignore +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/LICENSE +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pyproject.toml +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/__init__.py +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/aborted.svg +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/copy.svg +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/copy_ok.svg +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/failed.svg +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/running.svg +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/static/images/success.svg +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec/templates/__init__.py +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec.egg-info/SOURCES.txt +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec.egg-info/dependency_links.txt +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec.egg-info/entry_points.txt +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec.egg-info/requires.txt +0 -0
- {pywebexec-0.1.0 → pywebexec-1.0.0}/pywebexec.egg-info/top_level.txt +0 -0
- {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:
|
|
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 |
|
|
@@ -24,7 +24,7 @@ $ pywebexec
|
|
|
24
24
|
|
|
25
25
|
## features
|
|
26
26
|
|
|
27
|
-
* Serve executables in
|
|
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
|
|
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>
|
|
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>
|
|
@@ -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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|