pywebexec 1.9.2__tar.gz → 1.9.4__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 (57) hide show
  1. {pywebexec-1.9.2/pywebexec.egg-info → pywebexec-1.9.4}/PKG-INFO +20 -8
  2. {pywebexec-1.9.2 → pywebexec-1.9.4}/README.md +19 -7
  3. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/pywebexec.py +112 -11
  4. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/css/style.css +12 -0
  5. pywebexec-1.9.2/pywebexec/static/js/commands.js → pywebexec-1.9.4/pywebexec/static/js/executables.js +40 -6
  6. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/popup.js +2 -7
  7. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/script.js +11 -15
  8. pywebexec-1.9.4/pywebexec/swagger.yaml +209 -0
  9. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/templates/index.html +2 -1
  10. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/version.py +2 -2
  11. {pywebexec-1.9.2 → pywebexec-1.9.4/pywebexec.egg-info}/PKG-INFO +20 -8
  12. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec.egg-info/SOURCES.txt +1 -1
  13. pywebexec-1.9.2/pywebexec/swagger.yaml +0 -83
  14. {pywebexec-1.9.2 → pywebexec-1.9.4}/.github/workflows/python-publish.yml +0 -0
  15. {pywebexec-1.9.2 → pywebexec-1.9.4}/.gitignore +0 -0
  16. {pywebexec-1.9.2 → pywebexec-1.9.4}/LICENSE +0 -0
  17. {pywebexec-1.9.2 → pywebexec-1.9.4}/pyproject.toml +0 -0
  18. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/__init__.py +0 -0
  19. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/host_ip.py +0 -0
  20. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/css/xterm.css +0 -0
  21. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/fonts/CommitMonoNerdFontMono-Regular.ttf +0 -0
  22. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/fonts/LICENSE +0 -0
  23. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/aborted.svg +0 -0
  24. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/copy.svg +0 -0
  25. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/copy_ok.svg +0 -0
  26. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/down-arrow.svg +0 -0
  27. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/failed.svg +0 -0
  28. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/favicon.svg +0 -0
  29. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/fit-tty.svg +0 -0
  30. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/fit-win.svg +0 -0
  31. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/font-decrease.svg +0 -0
  32. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/font-increase.svg +0 -0
  33. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/norun.svg +0 -0
  34. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/pause.svg +0 -0
  35. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/popup.svg +0 -0
  36. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/resume.svg +0 -0
  37. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/running.svg +0 -0
  38. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/images/success.svg +0 -0
  39. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/LICENSE +0 -0
  40. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/addon-canvas.js +0 -0
  41. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/addon-canvas.js.map +0 -0
  42. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/addon-fit.js +0 -0
  43. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/addon-fit.js.map +0 -0
  44. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/addon-unicode-graphemes.js +0 -0
  45. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/addon-unicode-graphemes.js.map +0 -0
  46. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/addon-unicode11.js +0 -0
  47. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/addon-unicode11.js.map +0 -0
  48. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/xterm.js +0 -0
  49. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/static/js/xterm/xterm.js.map +0 -0
  50. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/templates/__init__.py +0 -0
  51. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec/templates/popup.html +0 -0
  52. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec.egg-info/dependency_links.txt +0 -0
  53. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec.egg-info/entry_points.txt +0 -0
  54. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec.egg-info/requires.txt +0 -0
  55. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexec.egg-info/top_level.txt +0 -0
  56. {pywebexec-1.9.2 → pywebexec-1.9.4}/pywebexecdev +0 -0
  57. {pywebexec-1.9.2 → pywebexec-1.9.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: pywebexec
3
- Version: 1.9.2
3
+ Version: 1.9.4
4
4
  Summary: Simple Python HTTP Exec Server
5
5
  Home-page: https://github.com/joknarf/pywebexec
6
6
  Author: Franck Jouvanceau
@@ -63,13 +63,13 @@ Requires-Dist: ldap3>=2.9.1
63
63
  Requires-Dist: pyte>=0.8.1
64
64
 
65
65
  [![Pypi version](https://img.shields.io/pypi/v/pywebexec.svg)](https://pypi.org/project/pywebexec/)
66
- ![example](https://github.com/joknarf/pywebexec/actions/workflows/python-publish.yml/badge.svg)
66
+ ![Publish Package](https://github.com/joknarf/pywebexec/actions/workflows/python-publish.yml/badge.svg)
67
67
  [![Licence](https://img.shields.io/badge/licence-MIT-blue.svg)](https://shields.io/)
68
68
  [![PyPI Downloads](https://static.pepy.tech/badge/pywebexec)](https://pepy.tech/projects/pywebexec)
69
69
  [![Python versions](https://img.shields.io/badge/python-3.6+-blue.svg)](https://shields.io/)
70
70
 
71
71
  # pywebexec
72
- Simple Python HTTP(S) API/Web Command Launcher and Terminal sharing
72
+ Simple Python HTTP(S) API/Web Server Command Launcher and Terminal sharing
73
73
 
74
74
  ## Install
75
75
  ```
@@ -79,6 +79,8 @@ $ pip install pywebexec
79
79
  ## Quick start
80
80
 
81
81
  * share terminal
82
+ * start http server and spawn a new terminal shared on 0.0.0.0 port 8080 (defaults)
83
+ * exiting terminal stops server/share
82
84
  ```shell
83
85
  $ pywebexec shareterm
84
86
  ```
@@ -100,6 +102,7 @@ all commands output / statuses are available in the executables directory in sub
100
102
  ## features
101
103
 
102
104
  * Serve executables in a directory
105
+ * full API driven with dynamic swagger UI
103
106
  * Launch commands with params from web browser or API call
104
107
  * multiple share terminal output
105
108
  * Follow live output
@@ -190,20 +193,29 @@ $ pywebexec stop
190
193
  ## Launch command through API
191
194
 
192
195
  ```shell
193
- $ curl http://myhost:8080/run_command -H 'Content-Type: application/json' -X POST -d '{ "command":"myscript", "params":["param1", ...]}'
194
- $ curl http://myhost:8080/command_status/<command_id>
195
- $ curl http://myhost:8080/command_output/<command_id> -H "Accept: text/plain"
196
+ $ curl http://myhost:8080/commands/myscript -H 'Content-Type: application/json' -X POST -d '{"params":["param1", ...]}'
197
+ $ curl http://myhost:8080/commands/<command_id>
198
+ $ curl http://myhost:8080/commands/<command_id>/output -H "Accept: text/plain"
196
199
  ```
197
200
 
201
+ ## Add help to commands
202
+
203
+ For each exposed command, you can add a help message by creating a file named `<command>.help` in the same directory as the command.
204
+ The help message is displayed:
205
+ * in the web interface as tooltip when focused on param input field,
206
+ * in the response when calling the API `/executables`
207
+ * in the swagger-ui in the `/commands/<command>` route.
208
+
198
209
  ## API reference
199
210
 
200
211
 
201
212
  | method | route | params/payload | returns
202
213
  |-----------|-----------------------------|--------------------|---------------------|
203
- | GET | /executables | | array of str |
204
- | 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<br>last_output_line: str |
214
+ | GET | /executables | | executables: [<br>&nbsp;&nbsp;{command: str,help: str},<br>] |
215
+ | GET | /commands | | commands: [<br>&nbsp;&nbsp;{<br>&nbsp;&nbsp;&nbsp;&nbsp;command_id: uuid<br>&nbsp;&nbsp;&nbsp;&nbsp;command: str<br>&nbsp;&nbsp;&nbsp;&nbsp;start_time: isotime<br>&nbsp;&nbsp;&nbsp;&nbsp;end_time: isotime<br>&nbsp;&nbsp;&nbsp;&nbsp;status: str<br>&nbsp;&nbsp;&nbsp;&nbsp;exit_code: int<br>&nbsp;&nbsp;&nbsp;&nbsp;last_output_line: str<br>&nbsp;&nbsp;},<br>] |
205
216
  | GET | /commands/{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<br>last_output_line: str |
206
217
  | GET | /commands/{id}/output | offset: int | output: str<br>status: str<br>links: { next: str } |
207
218
  | GET | /commands/{id}/output_raw | offset: int | output: stream raw output until end of command<br>curl -Ns http://srv/commands/{id}/output_raw|
208
219
  | POST | /commands | command: str<br>params: array[str]<br>rows: int<br>cols: int | command_id: uuid<br>message: str |
220
+ | POST | /commands/{cmd} | params: array[str]<br>rows: int<br>cols: int | command_id: uuid<br>message: str |
209
221
  | PATCH | /commands/{id}/stop | | message: str |
@@ -1,11 +1,11 @@
1
1
  [![Pypi version](https://img.shields.io/pypi/v/pywebexec.svg)](https://pypi.org/project/pywebexec/)
2
- ![example](https://github.com/joknarf/pywebexec/actions/workflows/python-publish.yml/badge.svg)
2
+ ![Publish Package](https://github.com/joknarf/pywebexec/actions/workflows/python-publish.yml/badge.svg)
3
3
  [![Licence](https://img.shields.io/badge/licence-MIT-blue.svg)](https://shields.io/)
4
4
  [![PyPI Downloads](https://static.pepy.tech/badge/pywebexec)](https://pepy.tech/projects/pywebexec)
5
5
  [![Python versions](https://img.shields.io/badge/python-3.6+-blue.svg)](https://shields.io/)
6
6
 
7
7
  # pywebexec
8
- Simple Python HTTP(S) API/Web Command Launcher and Terminal sharing
8
+ Simple Python HTTP(S) API/Web Server Command Launcher and Terminal sharing
9
9
 
10
10
  ## Install
11
11
  ```
@@ -15,6 +15,8 @@ $ pip install pywebexec
15
15
  ## Quick start
16
16
 
17
17
  * share terminal
18
+ * start http server and spawn a new terminal shared on 0.0.0.0 port 8080 (defaults)
19
+ * exiting terminal stops server/share
18
20
  ```shell
19
21
  $ pywebexec shareterm
20
22
  ```
@@ -36,6 +38,7 @@ all commands output / statuses are available in the executables directory in sub
36
38
  ## features
37
39
 
38
40
  * Serve executables in a directory
41
+ * full API driven with dynamic swagger UI
39
42
  * Launch commands with params from web browser or API call
40
43
  * multiple share terminal output
41
44
  * Follow live output
@@ -126,20 +129,29 @@ $ pywebexec stop
126
129
  ## Launch command through API
127
130
 
128
131
  ```shell
129
- $ curl http://myhost:8080/run_command -H 'Content-Type: application/json' -X POST -d '{ "command":"myscript", "params":["param1", ...]}'
130
- $ curl http://myhost:8080/command_status/<command_id>
131
- $ curl http://myhost:8080/command_output/<command_id> -H "Accept: text/plain"
132
+ $ curl http://myhost:8080/commands/myscript -H 'Content-Type: application/json' -X POST -d '{"params":["param1", ...]}'
133
+ $ curl http://myhost:8080/commands/<command_id>
134
+ $ curl http://myhost:8080/commands/<command_id>/output -H "Accept: text/plain"
132
135
  ```
133
136
 
137
+ ## Add help to commands
138
+
139
+ For each exposed command, you can add a help message by creating a file named `<command>.help` in the same directory as the command.
140
+ The help message is displayed:
141
+ * in the web interface as tooltip when focused on param input field,
142
+ * in the response when calling the API `/executables`
143
+ * in the swagger-ui in the `/commands/<command>` route.
144
+
134
145
  ## API reference
135
146
 
136
147
 
137
148
  | method | route | params/payload | returns
138
149
  |-----------|-----------------------------|--------------------|---------------------|
139
- | GET | /executables | | array of str |
140
- | 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<br>last_output_line: str |
150
+ | GET | /executables | | executables: [<br>&nbsp;&nbsp;{command: str,help: str},<br>] |
151
+ | GET | /commands | | commands: [<br>&nbsp;&nbsp;{<br>&nbsp;&nbsp;&nbsp;&nbsp;command_id: uuid<br>&nbsp;&nbsp;&nbsp;&nbsp;command: str<br>&nbsp;&nbsp;&nbsp;&nbsp;start_time: isotime<br>&nbsp;&nbsp;&nbsp;&nbsp;end_time: isotime<br>&nbsp;&nbsp;&nbsp;&nbsp;status: str<br>&nbsp;&nbsp;&nbsp;&nbsp;exit_code: int<br>&nbsp;&nbsp;&nbsp;&nbsp;last_output_line: str<br>&nbsp;&nbsp;},<br>] |
141
152
  | GET | /commands/{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<br>last_output_line: str |
142
153
  | GET | /commands/{id}/output | offset: int | output: str<br>status: str<br>links: { next: str } |
143
154
  | GET | /commands/{id}/output_raw | offset: int | output: stream raw output until end of command<br>curl -Ns http://srv/commands/{id}/output_raw|
144
155
  | POST | /commands | command: str<br>params: array[str]<br>rows: int<br>cols: int | command_id: uuid<br>message: str |
156
+ | POST | /commands/{cmd} | params: array[str]<br>rows: int<br>cols: int | command_id: uuid<br>message: str |
145
157
  | PATCH | /commands/{id}/stop | | message: str |
@@ -29,7 +29,8 @@ from pathlib import Path
29
29
  import pyte
30
30
  from . import host_ip
31
31
  from flask_swagger_ui import get_swaggerui_blueprint # new import
32
- import yaml # new import
32
+ import yaml
33
+ import html
33
34
 
34
35
  if os.environ.get('PYWEBEXEC_LDAP_SERVER'):
35
36
  from ldap3 import Server, Connection, ALL, SIMPLE, SUBTREE, Tls
@@ -167,12 +168,22 @@ def strip_ansi_control_chars(text):
167
168
  def decode_line(line: bytes) -> str:
168
169
  """try decode line exception on binary"""
169
170
  try:
170
- return get_visible_output(line.decode())
171
+ return get_visible_line(line.decode())
172
+ except UnicodeDecodeError:
173
+ return ""
174
+
175
+ def get_visible_output(output, cols, rows):
176
+ """pyte vt100 render to get last line"""
177
+ try:
178
+ screen = pyte.Screen(cols, rows)
179
+ stream = pyte.Stream(screen)
180
+ stream.feed(output)
181
+ return "\n".join(screen.display).strip()
171
182
  except UnicodeDecodeError:
172
183
  return ""
173
184
 
174
185
 
175
- def get_visible_output(line, cols, rows):
186
+ def get_visible_line(line, cols, rows):
176
187
  """pyte vt100 render to get last line"""
177
188
  try:
178
189
  screen = pyte.Screen(cols, rows)
@@ -197,7 +208,7 @@ def get_last_line(file_path, cols=None, rows=None, maxsize=2048):
197
208
  fd.seek(-maxsize, os.SEEK_END)
198
209
  except OSError:
199
210
  fd.seek(0)
200
- return get_visible_output(fd.read(), cols, rows)
211
+ return get_visible_line(fd.read(), cols, rows)
201
212
 
202
213
 
203
214
  def start_gunicorn(daemonized=False, baselog=None):
@@ -619,6 +630,19 @@ def log_error(fromip, user, message):
619
630
  def log_request(message):
620
631
  log_info(request.remote_addr, session.get('username', '-'), message)
621
632
 
633
+ def get_executables():
634
+ executables_list = []
635
+ for f in os.listdir('.'):
636
+ if os.path.isfile(f) and os.access(f, os.X_OK):
637
+ help_file = f"{f}.help"
638
+ help_text = ""
639
+ if os.path.exists(help_file) and os.path.isfile(help_file):
640
+ with open(help_file, 'r') as hf:
641
+ help_text = hf.read()
642
+ executables_list.append({"command": f, "help": help_text})
643
+ return executables_list
644
+
645
+
622
646
  @app.route('/commands/<command_id>/stop', methods=['PATCH'])
623
647
  def stop_command(command_id):
624
648
  log_request(f"stop_command {command_id}")
@@ -744,6 +768,37 @@ def run_command_endpoint():
744
768
 
745
769
  return jsonify({'message': 'Command is running', 'command_id': command_id})
746
770
 
771
+ @app.route('/commands/<cmd>', methods=['POST'])
772
+ def run_dynamic_command(cmd):
773
+ # Validate that 'cmd' is an executable in the current directory
774
+ cmd_path = os.path.join(".", os.path.basename(cmd))
775
+ if not os.path.isfile(cmd_path) or not os.access(cmd_path, os.X_OK):
776
+ return jsonify({'error': 'Command not found or not executable'}), 400
777
+ try:
778
+ data = request.json
779
+ except Exception as e:
780
+ data = {}
781
+ params = data.get('params', [])
782
+ rows = data.get('rows', tty_rows) or tty_rows
783
+ cols = data.get('cols', tty_cols) or tty_cols
784
+ try:
785
+ params = shlex.split(' '.join(params)) if isinstance(params, list) else []
786
+ except Exception as e:
787
+ return jsonify({'error': str(e)}), 400
788
+ user = session.get('username', '-')
789
+ command_id = str(uuid.uuid4())
790
+ update_command_status(command_id, {
791
+ 'status': 'running',
792
+ 'command': cmd,
793
+ 'params': params,
794
+ 'user': user,
795
+ 'from': request.remote_addr,
796
+ })
797
+ Path(get_output_file_path(command_id)).touch()
798
+ thread = threading.Thread(target=run_command, args=(request.remote_addr, user, cmd_path, params, command_id, rows, cols))
799
+ thread.start()
800
+ return jsonify({'message': 'Command is running', 'command_id': command_id})
801
+
747
802
  @app.route('/commands/<command_id>', methods=['GET'])
748
803
  def get_command_status(command_id):
749
804
  status = read_command_status(command_id)
@@ -757,15 +812,15 @@ def index():
757
812
 
758
813
  @app.route('/commands', methods=['GET'])
759
814
  def list_commands():
760
- # Sort commands by start_time in descending order
761
815
  commands = read_commands()
762
816
  commands.sort(key=lambda x: x['start_time'], reverse=True)
763
- return jsonify(commands)
817
+ return jsonify({"commands": commands})
764
818
 
765
819
  @app.route('/commands/<command_id>/output', methods=['GET'])
766
820
  def get_command_output(command_id):
767
821
  offset = int(request.args.get('offset', 0))
768
822
  maxsize = int(request.args.get('maxsize', 10485760))
823
+ maxlines = int(request.args.get('maxlines', 5000))
769
824
  output_file_path = get_output_file_path(command_id)
770
825
  if os.path.exists(output_file_path):
771
826
  size = os.path.getsize(output_file_path)
@@ -790,7 +845,7 @@ def get_command_output(command_id):
790
845
  }
791
846
  }
792
847
  if request.headers.get('Accept') == 'text/plain':
793
- return f"{output}\nstatus: {status_data.get('status')}", 200, {'Content-Type': 'text/plain'}
848
+ return f"{get_visible_output(output, status_data.get('cols'), maxlines)}\nstatus: {status_data.get('status')}", 200, {'Content-Type': 'text/plain'}
794
849
  return jsonify(response)
795
850
  return jsonify({'error': 'Invalid command_id'}), 404
796
851
 
@@ -820,9 +875,8 @@ def get_command_output_raw(command_id):
820
875
 
821
876
  @app.route('/executables', methods=['GET'])
822
877
  def list_executables():
823
- executables = [f for f in os.listdir('.') if os.path.isfile(f) and os.access(f, os.X_OK)]
824
- executables.sort() # Sort the list of executables alphabetically
825
- return jsonify(executables)
878
+ executables_list = get_executables()
879
+ return jsonify({"executables": executables_list})
826
880
 
827
881
  @app.route('/commands/<command_id>/popup')
828
882
  def popup(command_id):
@@ -854,7 +908,54 @@ def swagger_yaml():
854
908
  with open(swagger_path, 'r') as f:
855
909
  swagger_spec_str = f.read()
856
910
  swagger_spec = yaml.safe_load(swagger_spec_str)
857
- # Set title dynamically using the app configuration
911
+ # Update existing POST /commands enum if present
912
+ executables = get_executables()
913
+ post_cmd = swagger_spec.get('paths', {}).get('/commands', {}).get('post')
914
+ if post_cmd:
915
+ params_list = post_cmd.get('parameters', [])
916
+ for param in params_list:
917
+ if param.get('in') == 'body' and 'schema' in param:
918
+ props = param['schema'].get('properties', {})
919
+ if 'command' in props:
920
+ props['command']['enum'] = [e['command'] for e in executables]
921
+ # Add dynamic paths for each
922
+ # Add dynamic paths for each executable:
923
+ for exe in executables:
924
+ dynamic_path = "/commands/" + exe["command"]
925
+ swagger_spec.setdefault("paths", {})[dynamic_path] = {
926
+ "post": {
927
+ "summary": f"Run command {exe["command"]}",
928
+ "description": html.escape(exe["help"]),
929
+ "consumes": ["application/json"],
930
+ "produces": ["application/json"],
931
+ "parameters": [
932
+ {
933
+ "in": "body",
934
+ "name": "commandRequest",
935
+ "schema": {
936
+ "type": "object",
937
+ "properties": {
938
+ "params": {"type": "array", "items": {"type": "string"}, "default": []},
939
+ "rows": {"type": "integer", "default": tty_rows},
940
+ "cols": {"type": "integer", "default": tty_cols},
941
+ }
942
+ }
943
+ }
944
+ ],
945
+ "responses": {
946
+ "200": {
947
+ "description": "Command started",
948
+ "schema": {
949
+ "type": "object",
950
+ "properties": {
951
+ "message": {"type": "string"},
952
+ "command_id": {"type": "string"}
953
+ }
954
+ }
955
+ }
956
+ }
957
+ }
958
+ }
858
959
  swagger_spec['info']['title'] = app.config.get('TITLE', 'PyWebExec API')
859
960
  swagger_spec_str = yaml.dump(swagger_spec)
860
961
  return Response(swagger_spec_str, mimetype='application/yaml')
@@ -99,6 +99,18 @@ form {
99
99
  vertical-align: bottom;
100
100
  padding-left: 35px;
101
101
  }
102
+ #paramsHelp {
103
+ position: absolute;
104
+ background-color: #e0e0ff;
105
+ border: 1px solid #ccc;
106
+ padding: 4px 8px;
107
+ font-size: 0.9em;
108
+ display: none;
109
+ z-index: 1000;
110
+ white-space: pre;
111
+ border-radius: 5px;
112
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
113
+ }
102
114
  #thStatus {
103
115
  cursor: pointer;
104
116
  }
@@ -5,6 +5,8 @@ let commandListDiv = document.getElementById('commandList');
5
5
  let showCommandListButton = document.getElementById('showCommandListButton');
6
6
  let isHandlingKeydown = false;
7
7
  let firstVisibleItem = 0;
8
+ let gExecutables = {};
9
+ let helpDiv = document.getElementById('paramsHelp');
8
10
 
9
11
  function unfilterCommands() {
10
12
  const items = commandListDiv.children;
@@ -43,6 +45,13 @@ function setCommandListPosition() {
43
45
  commandListDiv.style.top = `${rect.bottom}px`;
44
46
  }
45
47
 
48
+ // Update helpDiv position relative to paramsInput
49
+ function setHelpDivPosition() {
50
+ const rect = paramsInput.getBoundingClientRect();
51
+ helpDiv.style.left = `${rect.left}px`;
52
+ helpDiv.style.top = `${rect.bottom + 2}px`;
53
+ }
54
+
46
55
  function adjustInputWidth(input) {
47
56
  input.style.width = 'auto';
48
57
  input.style.width = `${input.scrollWidth}px`;
@@ -216,17 +225,23 @@ window.addEventListener('load', () => {
216
225
 
217
226
  async function fetchExecutables() {
218
227
  try {
219
- const response = await fetch(`/executables${urlToken}`);
228
+ const response = await fetch(`/executables`);
220
229
  if (!response.ok) {
221
- throw new Error('Failed to fetch command status');
230
+ throw new Error('Failed to fetch executables');
222
231
  }
223
- const executables = await response.json();
232
+ const data = await response.json();
233
+ // Build mapping from executable name to its object
234
+ gExecutables = {};
224
235
  commandListDiv.innerHTML = '';
225
- executables.forEach(executable => {
236
+ data.executables.forEach(exeObj => {
237
+ gExecutables[exeObj.command] = exeObj;
226
238
  const div = document.createElement('div');
227
239
  div.className = 'command-item';
228
- div.textContent = executable;
229
- div.tabIndex = 0; // Make the div focusable
240
+ div.textContent = exeObj.command;
241
+ div.tabIndex = 0;
242
+ if (exeObj.help) {
243
+ div.title = exeObj.help;
244
+ }
230
245
  commandListDiv.appendChild(div);
231
246
  });
232
247
  } catch (error) {
@@ -240,3 +255,22 @@ async function fetchExecutables() {
240
255
  document.getElementById('launchForm').style.display = 'none';
241
256
 
242
257
  }
258
+
259
+ paramsInput.addEventListener('focus', () => {
260
+ const currentCmd = commandInput.value;
261
+ if (gExecutables[currentCmd] && gExecutables[currentCmd].help) {
262
+ //helpDiv.innerHTML = gExecutables[currentCmd].help.replace(/\n/g, '<br>');
263
+ helpDiv.innerText = gExecutables[currentCmd].help;
264
+ setHelpDivPosition();
265
+ helpDiv.style.display = 'block';
266
+ } else {
267
+ helpDiv.style.display = 'none';
268
+ }
269
+ });
270
+
271
+ paramsInput.addEventListener('blur', () => {
272
+ helpDiv.style.display = 'none';
273
+ });
274
+
275
+ window.addEventListener('resize', setHelpDivPosition);
276
+ window.addEventListener('scroll', setHelpDivPosition);
@@ -98,11 +98,6 @@ function autoFit(scroll=true) {
98
98
  if (scroll) terminal.scrollToBottom();
99
99
  }
100
100
 
101
- function getTokenParam() {
102
- const urlParams = new URLSearchParams(window.location.search);
103
- return urlParams.get('token') ? `?token=${urlParams.get('token')}` : '';
104
- }
105
- const urlToken = getTokenParam();
106
101
 
107
102
 
108
103
  function setCommandStatus(status) {
@@ -161,14 +156,14 @@ async function viewOutput(command_id) {
161
156
  slider.value = 1000;
162
157
  adjustOutputHeight();
163
158
  currentCommandId = command_id;
164
- nextOutputLink = `/commands/${command_id}/output${urlToken}`;
159
+ nextOutputLink = `/commands/${command_id}/output`;
165
160
  clearInterval(outputInterval);
166
161
  terminal.clear();
167
162
  terminal.reset();
168
163
  fullOutput = '';
169
164
  try {
170
165
  // Updated endpoint below:
171
- const response = await fetch(`/commands/${command_id}${urlToken}`);
166
+ const response = await fetch(`/commands/${command_id}`);
172
167
  if (!response.ok) {
173
168
  return;
174
169
  }
@@ -108,12 +108,6 @@ function autoFit(scroll=true) {
108
108
  if (scroll) terminal.scrollToBottom();
109
109
  }
110
110
 
111
- function getTokenParam() {
112
- const urlParams = new URLSearchParams(window.location.search);
113
- return urlParams.get('token') ? `?token=${urlParams.get('token')}` : '';
114
- }
115
- const urlToken = getTokenParam();
116
-
117
111
 
118
112
  document.getElementById('launchForm').addEventListener('submit', async (event) => {
119
113
  event.preventDefault();
@@ -122,7 +116,7 @@ document.getElementById('launchForm').addEventListener('submit', async (event) =
122
116
  fitAddon.fit();
123
117
  terminal.clear();
124
118
  try {
125
- const response = await fetch(`/commands${urlToken}`, {
119
+ const response = await fetch(`/commands`, {
126
120
  method: 'POST',
127
121
  headers: {
128
122
  'Content-Type': 'application/json'
@@ -144,12 +138,14 @@ document.getElementById('launchForm').addEventListener('submit', async (event) =
144
138
 
145
139
  async function fetchCommands(hide=false) {
146
140
  try {
147
- const response = await fetch(`/commands${urlToken}`);
141
+ const response = await fetch(`/commands`);
148
142
  if (!response.ok) {
149
143
  document.getElementById('dimmer').style.display = 'block';
150
144
  return;
151
145
  }
152
- const commands = await response.json();
146
+ // Adapt to the new result structure:
147
+ const data = await response.json();
148
+ const commands = data.commands;
153
149
  commands.sort((a, b) => new Date(b.start_time) - new Date(a.start_time));
154
150
  const commandsTbody = document.getElementById('commands');
155
151
  commandsTbody.innerHTML = '';
@@ -252,13 +248,13 @@ async function viewOutput(command_id) {
252
248
  outputPercentage.innerText = '100%';
253
249
  adjustOutputHeight();
254
250
  currentCommandId = command_id;
255
- nextOutputLink = `/commands/${command_id}/output${urlToken}`; // updated URL
251
+ nextOutputLink = `/commands/${command_id}/output`;
256
252
  clearInterval(outputInterval);
257
253
  terminal.clear();
258
254
  terminal.reset();
259
255
  fullOutput = '';
260
256
  try {
261
- const response = await fetch(`/commands/${command_id}${urlToken}`);
257
+ const response = await fetch(`/commands/${command_id}`);
262
258
  if (!response.ok) {
263
259
  outputInterval = setInterval(() => fetchOutput(nextOutputLink), 500);
264
260
  }
@@ -289,7 +285,7 @@ async function viewOutput(command_id) {
289
285
  async function openPopup(command_id, event) {
290
286
  event.stopPropagation();
291
287
  event.stopImmediatePropagation();
292
- const popupUrl = `/commands/${command_id}/popup${urlToken}`;
288
+ const popupUrl = `/commands/${command_id}/popup`;
293
289
  window.open(popupUrl, '_blank', 'width=1000,height=600');
294
290
  }
295
291
 
@@ -297,7 +293,7 @@ async function relaunchCommand(command_id, event) {
297
293
  event.stopPropagation();
298
294
  event.stopImmediatePropagation();
299
295
  try {
300
- const response = await fetch(`/commands/${command_id}${urlToken}`);
296
+ const response = await fetch(`/commands/${command_id}`);
301
297
  if (!response.ok) {
302
298
  throw new Error('Failed to fetch command status');
303
299
  }
@@ -308,7 +304,7 @@ async function relaunchCommand(command_id, event) {
308
304
  }
309
305
  fitAddon.fit();
310
306
  terminal.clear();
311
- const relaunchResponse = await fetch(`/commands${urlToken}`, {
307
+ const relaunchResponse = await fetch(`/commands`, {
312
308
  method: 'POST',
313
309
  headers: {
314
310
  'Content-Type': 'application/json'
@@ -336,7 +332,7 @@ async function stopCommand(command_id, event) {
336
332
  event.stopPropagation();
337
333
  event.stopImmediatePropagation();
338
334
  try {
339
- const response = await fetch(`/commands/${command_id}/stop${urlToken}`, {
335
+ const response = await fetch(`/commands/${command_id}/stop`, {
340
336
  method: 'PATCH'
341
337
  });
342
338
  if (!response.ok) {
@@ -0,0 +1,209 @@
1
+ swagger: "2.0"
2
+ info:
3
+ title: PyWebExec API
4
+ version: "1.0"
5
+ paths:
6
+ /commands:
7
+ get:
8
+ summary: "List commands status"
9
+ responses:
10
+ "200":
11
+ description: "List of all commands status"
12
+ schema:
13
+ type: object
14
+ properties:
15
+ commands:
16
+ type: array
17
+ items:
18
+ type: object
19
+ properties:
20
+ command_id:
21
+ type: string
22
+ command:
23
+ type: string
24
+ status:
25
+ type: string
26
+ start_time:
27
+ type: string
28
+ format: date-time
29
+ end_time:
30
+ type: string
31
+ format: date-time
32
+ exit_code:
33
+ type: integer
34
+ last_output_line:
35
+ type: string
36
+ user:
37
+ type: string
38
+ post:
39
+ summary: "Run a command"
40
+ consumes:
41
+ - application/json
42
+ produces:
43
+ - application/json
44
+ parameters:
45
+ - in: body
46
+ name: commandRequest
47
+ schema:
48
+ type: object
49
+ properties:
50
+ command:
51
+ type: string
52
+ # Enum will be added dynamically by the APIs
53
+ default: commandName
54
+ params:
55
+ type: array
56
+ items:
57
+ type: string
58
+ default: []
59
+ rows:
60
+ type: integer
61
+ default: 24
62
+ cols:
63
+ type: integer
64
+ default: 125
65
+ required:
66
+ - command
67
+ responses:
68
+ "200":
69
+ description: "Command started"
70
+ schema:
71
+ type: object
72
+ properties:
73
+ command_id:
74
+ type: string
75
+ message:
76
+ type: string
77
+ /commands/{command_id}:
78
+ get:
79
+ summary: "Get command status"
80
+ parameters:
81
+ - in: path
82
+ name: command_id
83
+ required: true
84
+ type: string
85
+ responses:
86
+ "200":
87
+ description: "Command status returned"
88
+ schema:
89
+ type: object
90
+ properties:
91
+ command_id:
92
+ type: string
93
+ command:
94
+ type: string
95
+ params:
96
+ type: array
97
+ items:
98
+ type: string
99
+ status:
100
+ type: string
101
+ start_time:
102
+ type: string
103
+ format: date-time
104
+ end_time:
105
+ type: string
106
+ format: date-time
107
+ exit_code:
108
+ type: integer
109
+ last_output_line:
110
+ type: string
111
+ cols:
112
+ type: integer
113
+ rows:
114
+ type: integer
115
+ user:
116
+ type: string
117
+ from:
118
+ type: string
119
+ pid:
120
+ type: integer
121
+
122
+ /commands/{command_id}/output:
123
+ get:
124
+ summary: "Get command output"
125
+ parameters:
126
+ - in: path
127
+ name: command_id
128
+ required: true
129
+ type: string
130
+ - in: query
131
+ name: offset
132
+ type: integer
133
+ default: 0
134
+ - in: query
135
+ name: maxsize
136
+ type: integer
137
+ default: 10485760
138
+ - in: query
139
+ name: maxlines
140
+ type: integer
141
+ default: 5000
142
+ consumes:
143
+ - application/json
144
+ produces:
145
+ - application/json
146
+ - text/plain
147
+ responses:
148
+ "200":
149
+ description: "Command output returned"
150
+ schema:
151
+ type: object
152
+ properties:
153
+ output:
154
+ type: string
155
+ status:
156
+ type: string
157
+ rows:
158
+ type: integer
159
+ cols:
160
+ type: integer
161
+ links:
162
+ type: object
163
+ properties:
164
+ next:
165
+ type: string
166
+ format: uri
167
+ /commands/{command_id}/stop:
168
+ patch:
169
+ summary: "Stop a running command"
170
+ parameters:
171
+ - in: path
172
+ name: command_id
173
+ required: true
174
+ type: string
175
+ responses:
176
+ "200":
177
+ description: "Command stopped successfully"
178
+ schema:
179
+ type: object
180
+ properties:
181
+ message:
182
+ type: string
183
+ /executables:
184
+ get:
185
+ summary: "List available executable commands"
186
+ produces:
187
+ - application/json
188
+ responses:
189
+ "200":
190
+ description: "List of executables returned as an array of executable objects"
191
+ schema:
192
+ type: object
193
+ properties:
194
+ executables:
195
+ type: array
196
+ items:
197
+ type: object
198
+ properties:
199
+ command:
200
+ type: string
201
+ help:
202
+ type: string
203
+ examples:
204
+ application/json:
205
+ executables:
206
+ - command: ls
207
+ help: "List directory contents\nparams:\n <ls_params>"
208
+ - command: cat
209
+ help: "Concatenate and display files\nparams:\n <cat_params>"
@@ -21,6 +21,7 @@
21
21
  </div>
22
22
  <div id="commandList" class="command-list"></div>
23
23
  <input type="text" id="params" name="params" oninput="this.style.width = ((this.value.length + 1) * 8) + 'px';">
24
+ <pre id="paramsHelp"></pre>
24
25
  </div>
25
26
  <button type="submit">Run</button>
26
27
  </form>
@@ -60,6 +61,6 @@
60
61
  <script src="/static/js/xterm/addon-unicode-graphemes.js"></script>
61
62
  <script src="/static/js/xterm/addon-fit.js"></script>
62
63
  <script type="text/javascript" src="/static/js/script.js"></script>
63
- <script type="text/javascript" src="/static/js/commands.js"></script>
64
+ <script type="text/javascript" src="/static/js/executables.js"></script>
64
65
  </body>
65
66
  </html>
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '1.9.2'
21
- __version_tuple__ = version_tuple = (1, 9, 2)
20
+ __version__ = version = '1.9.4'
21
+ __version_tuple__ = version_tuple = (1, 9, 4)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: pywebexec
3
- Version: 1.9.2
3
+ Version: 1.9.4
4
4
  Summary: Simple Python HTTP Exec Server
5
5
  Home-page: https://github.com/joknarf/pywebexec
6
6
  Author: Franck Jouvanceau
@@ -63,13 +63,13 @@ Requires-Dist: ldap3>=2.9.1
63
63
  Requires-Dist: pyte>=0.8.1
64
64
 
65
65
  [![Pypi version](https://img.shields.io/pypi/v/pywebexec.svg)](https://pypi.org/project/pywebexec/)
66
- ![example](https://github.com/joknarf/pywebexec/actions/workflows/python-publish.yml/badge.svg)
66
+ ![Publish Package](https://github.com/joknarf/pywebexec/actions/workflows/python-publish.yml/badge.svg)
67
67
  [![Licence](https://img.shields.io/badge/licence-MIT-blue.svg)](https://shields.io/)
68
68
  [![PyPI Downloads](https://static.pepy.tech/badge/pywebexec)](https://pepy.tech/projects/pywebexec)
69
69
  [![Python versions](https://img.shields.io/badge/python-3.6+-blue.svg)](https://shields.io/)
70
70
 
71
71
  # pywebexec
72
- Simple Python HTTP(S) API/Web Command Launcher and Terminal sharing
72
+ Simple Python HTTP(S) API/Web Server Command Launcher and Terminal sharing
73
73
 
74
74
  ## Install
75
75
  ```
@@ -79,6 +79,8 @@ $ pip install pywebexec
79
79
  ## Quick start
80
80
 
81
81
  * share terminal
82
+ * start http server and spawn a new terminal shared on 0.0.0.0 port 8080 (defaults)
83
+ * exiting terminal stops server/share
82
84
  ```shell
83
85
  $ pywebexec shareterm
84
86
  ```
@@ -100,6 +102,7 @@ all commands output / statuses are available in the executables directory in sub
100
102
  ## features
101
103
 
102
104
  * Serve executables in a directory
105
+ * full API driven with dynamic swagger UI
103
106
  * Launch commands with params from web browser or API call
104
107
  * multiple share terminal output
105
108
  * Follow live output
@@ -190,20 +193,29 @@ $ pywebexec stop
190
193
  ## Launch command through API
191
194
 
192
195
  ```shell
193
- $ curl http://myhost:8080/run_command -H 'Content-Type: application/json' -X POST -d '{ "command":"myscript", "params":["param1", ...]}'
194
- $ curl http://myhost:8080/command_status/<command_id>
195
- $ curl http://myhost:8080/command_output/<command_id> -H "Accept: text/plain"
196
+ $ curl http://myhost:8080/commands/myscript -H 'Content-Type: application/json' -X POST -d '{"params":["param1", ...]}'
197
+ $ curl http://myhost:8080/commands/<command_id>
198
+ $ curl http://myhost:8080/commands/<command_id>/output -H "Accept: text/plain"
196
199
  ```
197
200
 
201
+ ## Add help to commands
202
+
203
+ For each exposed command, you can add a help message by creating a file named `<command>.help` in the same directory as the command.
204
+ The help message is displayed:
205
+ * in the web interface as tooltip when focused on param input field,
206
+ * in the response when calling the API `/executables`
207
+ * in the swagger-ui in the `/commands/<command>` route.
208
+
198
209
  ## API reference
199
210
 
200
211
 
201
212
  | method | route | params/payload | returns
202
213
  |-----------|-----------------------------|--------------------|---------------------|
203
- | GET | /executables | | array of str |
204
- | 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<br>last_output_line: str |
214
+ | GET | /executables | | executables: [<br>&nbsp;&nbsp;{command: str,help: str},<br>] |
215
+ | GET | /commands | | commands: [<br>&nbsp;&nbsp;{<br>&nbsp;&nbsp;&nbsp;&nbsp;command_id: uuid<br>&nbsp;&nbsp;&nbsp;&nbsp;command: str<br>&nbsp;&nbsp;&nbsp;&nbsp;start_time: isotime<br>&nbsp;&nbsp;&nbsp;&nbsp;end_time: isotime<br>&nbsp;&nbsp;&nbsp;&nbsp;status: str<br>&nbsp;&nbsp;&nbsp;&nbsp;exit_code: int<br>&nbsp;&nbsp;&nbsp;&nbsp;last_output_line: str<br>&nbsp;&nbsp;},<br>] |
205
216
  | GET | /commands/{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<br>last_output_line: str |
206
217
  | GET | /commands/{id}/output | offset: int | output: str<br>status: str<br>links: { next: str } |
207
218
  | GET | /commands/{id}/output_raw | offset: int | output: stream raw output until end of command<br>curl -Ns http://srv/commands/{id}/output_raw|
208
219
  | POST | /commands | command: str<br>params: array[str]<br>rows: int<br>cols: int | command_id: uuid<br>message: str |
220
+ | POST | /commands/{cmd} | params: array[str]<br>rows: int<br>cols: int | command_id: uuid<br>message: str |
209
221
  | PATCH | /commands/{id}/stop | | message: str |
@@ -36,7 +36,7 @@ pywebexec/static/images/popup.svg
36
36
  pywebexec/static/images/resume.svg
37
37
  pywebexec/static/images/running.svg
38
38
  pywebexec/static/images/success.svg
39
- pywebexec/static/js/commands.js
39
+ pywebexec/static/js/executables.js
40
40
  pywebexec/static/js/popup.js
41
41
  pywebexec/static/js/script.js
42
42
  pywebexec/static/js/xterm/LICENSE
@@ -1,83 +0,0 @@
1
- swagger: "2.0"
2
- info:
3
- title: PyWebExec API
4
- version: "1.0"
5
- paths:
6
- /commands:
7
- get:
8
- summary: "List commands status"
9
- responses:
10
- "200":
11
- description: "List of all commands status"
12
- post:
13
- summary: "Run a command"
14
- consumes:
15
- - application/json
16
- produces:
17
- - application/json
18
- parameters:
19
- - in: body
20
- name: commandRequest
21
- schema:
22
- type: object
23
- properties:
24
- command:
25
- type: string
26
- params:
27
- type: array
28
- items:
29
- type: string
30
- rows:
31
- type: integer
32
- cols:
33
- type: integer
34
- responses:
35
- "200":
36
- description: "Command started"
37
- /commands/{command_id}:
38
- get:
39
- summary: "Get command status"
40
- parameters:
41
- - in: path
42
- name: command_id
43
- required: true
44
- type: string
45
- responses:
46
- "200":
47
- description: "Command status returned"
48
- /commands/{command_id}/output:
49
- get:
50
- summary: "Get command output"
51
- parameters:
52
- - in: path
53
- name: command_id
54
- required: true
55
- type: string
56
- - in: query
57
- name: offset
58
- type: integer
59
- default: 0
60
- - in: query
61
- name: maxsize
62
- type: integer
63
- default: 10485760
64
- responses:
65
- "200":
66
- description: "Command output returned"
67
- /commands/{command_id}/stop:
68
- patch:
69
- summary: "Stop a running command"
70
- parameters:
71
- - in: path
72
- name: command_id
73
- required: true
74
- type: string
75
- responses:
76
- "200":
77
- description: "Command stopped successfully"
78
- /executables:
79
- get:
80
- summary: "List available executable commands"
81
- responses:
82
- "200":
83
- description: "List of executables returned as an array of executable names"
File without changes
File without changes
File without changes
File without changes
File without changes