├── .gitignore ├── LICENSE ├── README.md ├── app.py ├── config_sample.py ├── flask_remote_term_chart.png ├── flask_remote_term_demo.jpg ├── index.html └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### macOS template 3 | *.DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two \r 8 | Icon 9 | 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | ### Python template 30 | # Byte-compiled / optimized / DLL files 31 | __pycache__/ 32 | *.py[cod] 33 | *$py.class 34 | 35 | # C extensions 36 | *.so 37 | 38 | # Distribution / packaging 39 | .Python 40 | env/ 41 | build/ 42 | develop-eggs/ 43 | dist/ 44 | downloads/ 45 | eggs/ 46 | .eggs/ 47 | lib/ 48 | lib64/ 49 | parts/ 50 | sdist/ 51 | var/ 52 | wheels/ 53 | *.egg-info/ 54 | .installed.cfg 55 | *.egg 56 | 57 | # PyInstaller 58 | # Usually these files are written by a python script from a template 59 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 60 | *.manifest 61 | *.spec 62 | 63 | # Installer logs 64 | pip-log.txt 65 | pip-delete-this-directory.txt 66 | 67 | # Unit test / coverage reports 68 | htmlcov/ 69 | .tox/ 70 | .coverage 71 | .coverage.* 72 | .cache 73 | nosetests.xml 74 | coverage.xml 75 | *,cover 76 | .hypothesis/ 77 | 78 | # Translations 79 | *.mo 80 | *.pot 81 | 82 | # Django stuff: 83 | *.log 84 | local_settings.py 85 | 86 | # Flask stuff: 87 | instance/ 88 | .webassets-cache 89 | 90 | # Scrapy stuff: 91 | .scrapy 92 | 93 | # Sphinx documentation 94 | docs/_build/ 95 | 96 | # PyBuilder 97 | target/ 98 | 99 | # Jupyter Notebook 100 | .ipynb_checkpoints 101 | 102 | # pyenv 103 | .python-version 104 | 105 | # celery beat schedule file 106 | celerybeat-schedule 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # dotenv 112 | .env 113 | 114 | # virtualenv 115 | .venv 116 | venv/ 117 | ENV/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # pycharm stuff 126 | .idea/ 127 | 128 | # user defined 129 | config.py 130 | flask_session/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Fisherworks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask Web Terminal to Remote 2 | A web terminal to access certain remote ssh/telnet server with multi-user support 3 | 4 | [README 中文版](http://fisherworks.cn/?p=2848) 5 | 6 | ![screenshot](https://github.com/Fisherworks/flask-remote-terminal/blob/master/flask_remote_term_demo.jpg) 7 | 8 | ## What makes this unique 9 | * Created for remote access and remote-only, instead of wandering on the local 10 | * Telnet and SSH client only (for now), can be added further (but still well controlled) 11 | * Made to access certain/specific target only (setup in config), away from being sidekick of villains 12 | * Multiple users supported by (server-side) session based key info storage 13 | 14 | ![sys_chart](https://github.com/Fisherworks/flask-remote-terminal/blob/master/flask_remote_term_chart.png) 15 | 16 | ## Based on 17 | * A concept from github repo [pyxterm.js](https://github.com/cs01/pyxterm.js) by [cs01](https://github.com/cs01) 18 | 19 | 20 | ## Use cases of this "weirdo" 21 | A handy (web) console to manage a certain intermediate host which has its bunch ports proxying the targets that really matter 22 | * Like all web terminals, the only thing needed is a web browser (that has support to websocket), no client (such as putty) installation needed 23 | * Target of the web console can reach should be limited to the designated (things like a gateway) only 24 | * Use cases should also be well controlled, such as telnet and ssh here, as limited options 25 | * Local server that provide this web console should NOT be easily messed around 26 | 27 | 28 | ## Progress 29 | A meaningful prototype that works 30 | 31 | ## Meanings 32 | And beside what's mentioned in [pyxterm.js](https://github.com/cs01/pyxterm.js#why) - 33 | 34 | * learn to import [server-side session](https://blog.miguelgrinberg.com/post/flask-socketio-and-the-user-session) (by flask-session) to serve multiple users 35 | * Use the [default rooms of socketio](https://github.com/miguelgrinberg/Flask-SocketIO/tree/master/example) to split the backends for different visitors 36 | 37 | 38 | ## Deployment 39 | 40 | Clone this repository, get into the folder, then run: 41 | 42 | ``` 43 | python3 -m venv venv # must be python3.6+ 44 | venv/bin/pip install -r requirements.txt 45 | ``` 46 | Now copy the file `config_sample.py` to `config.py`, then edit the `domain` to be your remote target, either ip addr or domain name should work. 47 | 48 | Make sure you have the telnet and/or ssh client binary installed on the server, with absolute paths here in the config. 49 | For example, if the server OS is macOS, and telnet was installed through Brew, then the telnet path could be `/usr/local/bin/telnet` 50 | 51 | Example: 52 | ``` 53 | TERM_INIT_CONFIG = { 54 | 'domain': 'example.com', # or ip address like 192.168.10.11 55 | 'client_path': { 56 | 'telnet': '/usr/bin/telnet', # confirmed location of your client binary (with cmd like 'which telnet') 57 | 'ssh': '/usr/bin/ssh' 58 | } 59 | } 60 | ``` 61 | Run `python app.py` or `gunicorn -b 0.0.0.0:5000 -k gevent -w 1 app:app` 62 | 63 | Visit `http://127.0.0.1:5000/remote///` to try it. 64 | 65 | Example: 66 | ``` 67 | http://127.0.0.1:5000/remote/ssh/usertest/6022 # ssh -p 6022 usertest@192.168.11.111 68 | http://127.0.0.1:5000/remote/telnet/usertest/7023 # telnet -l usertest 192.168.11.111 7023 69 | ``` 70 | 71 | ## Known issue(s) 72 | * This can also be served by `uwsgi --socket :5000 --gevent 1000 --master --wsgi-file=./app.py --callable app` as a more friendly production deployment, but the telnet part won't survive by unknown cause while the ssh works quite well. If anyone can get this resolved, pls let me know - this is a better option for integration with nginx, as far as I know. 73 | * Don't even consider to run the server with more than 1 worker, no matter through uwsgi or gunicorn, unless consideration of phasing in a message queue. Talking about this here is beyond the scope, feel free to find out the documents from [Flask-SocketIO](https://flask-socketio.readthedocs.io/en/latest/#using-multiple-workers). 74 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from flask import Flask, render_template, session, abort 3 | from flask_session import Session 4 | from flask_socketio import SocketIO, disconnect, rooms 5 | import pty 6 | import os 7 | import select 8 | import termios 9 | import struct 10 | import fcntl 11 | import psutil 12 | import subprocess 13 | from config import TERM_INIT_CONFIG 14 | 15 | 16 | __author__ = "fisherworks.cn" #based on flask_term_remote on github 17 | 18 | app = Flask(__name__, template_folder=".", static_folder=".", static_url_path="") 19 | app.config["SECRET_KEY"] = "the top secret!" 20 | app.config['SESSION_TYPE'] = 'filesystem' 21 | Session(app) 22 | # according to blog post of Miguel Grinberg, the author of Flask-SocketIO 23 | # manage_session should be set to False, only if you have server_side session 24 | # and you also want a bi-directional sharing of session between Flask and Flask-SocketIO 25 | socketio = SocketIO(app, manage_session=False, logger=False, engineio_logger=False) 26 | 27 | 28 | def set_winsize(fd, row, col, xpix=0, ypix=0): 29 | winsize = struct.pack("HHHH", row, col, xpix, ypix) 30 | fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) 31 | 32 | 33 | def read_and_forward_pty_output(fd=None, pid=None, room_id=None): 34 | """ 35 | read data on pty master from the pty slave, and emit to the web terminal visitor 36 | """ 37 | max_read_bytes = 1024 * 20 38 | while True: 39 | socketio.sleep(0.15) 40 | # using flask default web server, or uwsgi production web server 41 | # when the child process is terminated, it will not disappear from linux process list 42 | # and keep staying as a zombie process until the parent exits. 43 | try: 44 | child_process = psutil.Process(pid) 45 | except psutil.NoSuchProcess as err: 46 | return 47 | if child_process.status() not in ('running', 'sleeping'): 48 | return 49 | # print('background running') 50 | if fd: 51 | timeout_sec = 0 52 | (data_ready, _, _) = select.select([fd], [], [], timeout_sec) 53 | if data_ready: 54 | # output = os.read(fd, max_read_bytes).decode('ascii') 55 | try: 56 | output = os.read(fd, max_read_bytes).decode() 57 | except Exception as err: 58 | output = """ 59 | ***AQUI WEB TERM ERR*** 60 | {} 61 | *********************** 62 | """.format(err) 63 | # the key for different visitor to get different terminal (instead of mixing up) 64 | # is to let the background task push pty response to each one's own (default) ROOM! 65 | socketio.emit("pty-output", {"output": output}, namespace="/pty", room=room_id) 66 | 67 | 68 | @app.route("/") 69 | def index(): 70 | return 'this is working' 71 | 72 | 73 | @app.route("/remote///", methods=['GET']) 74 | def remote_conn(term_type, username, port): 75 | # put uname and port into session of every single visitor 76 | if term_type not in ('ssh', 'telnet'): 77 | return abort(404, 'wrong terminal type, can only be either ssh or telnet') 78 | session['terminal_config'] = TERM_INIT_CONFIG 79 | session['terminal_config']['term_type'] = term_type 80 | session['terminal_config']['username'] = username 81 | session['terminal_config']['port'] = port 82 | session.modified = True 83 | return render_template("index.html") 84 | 85 | 86 | @socketio.on("pty-input", namespace="/pty") 87 | def pty_input(data): 88 | """write to the child pty, which now is the ssh process from this machine to the 'domain' configured 89 | """ 90 | try: 91 | child_process = psutil.Process(session.get('terminal_config').get('child_pid')) 92 | except psutil.NoSuchProcess as err: 93 | disconnect() 94 | session['terminal_config'] = TERM_INIT_CONFIG 95 | return 96 | if child_process.status() not in ('running', 'sleeping'): 97 | disconnect() 98 | session['terminal_config'] = TERM_INIT_CONFIG 99 | return 100 | # print(session) 101 | # print(data, 'from input') 102 | fd = session.get('terminal_config').get('fd') 103 | if fd: 104 | # print("writing to ptd: %s" % data["input"]) 105 | # os.write(fd, data["input"].encode('ascii')) 106 | os.write(fd, data["input"].encode()) 107 | 108 | 109 | @socketio.on("resize", namespace="/pty") 110 | def resize(data): 111 | try: 112 | child_process = psutil.Process(session.get('terminal_config').get('child_pid')) 113 | except psutil.NoSuchProcess as err: 114 | disconnect() 115 | session['terminal_config'] = TERM_INIT_CONFIG 116 | return 117 | if child_process.status() not in ('running', 'sleeping'): 118 | disconnect() 119 | session['terminal_config'] = TERM_INIT_CONFIG 120 | return 121 | fd = session.get('terminal_config').get('fd') 122 | if fd: 123 | set_winsize(fd, data["rows"], data["cols"]) 124 | 125 | 126 | @socketio.on("connect", namespace="/pty") 127 | def pty_connect(): 128 | """new client connected""" 129 | 130 | if session.get('terminal_config', {}).get('child_pid', None): 131 | print(session['terminal_config']['child_pid']) 132 | # already started child process, don't start another 133 | return 134 | 135 | # create child process attached to a pty we can read from and write to 136 | (child_pid, fd) = pty.fork() 137 | if child_pid == 0: 138 | # this is the child process fork. 139 | # anything printed here will show up in the pty, including the output 140 | # of this subprocess 141 | # subprocess.run('bash') 142 | term_type = session.get('terminal_config').get('term_type') 143 | path = TERM_INIT_CONFIG.get('client_path', {}).get(term_type, None) 144 | if not path: 145 | print("Can't locate {} binary, exit".format(term_type)) 146 | disconnect() 147 | if term_type == 'telnet': 148 | # switch to the right location of your telnet binary (example comes from OSX which got telnet from brew) 149 | # or you can also make work like auto-detection, or manually but configurable 150 | os.execl(path, 'telnet', '-l', session['terminal_config']['username'], 151 | session['terminal_config']['domain'], '{}'.format(session['terminal_config']['port'])) 152 | elif term_type == 'ssh': 153 | # switch to the right location of your ssh binary 154 | # or you can also make work like auto-detection, or manually but configurable 155 | os.execl(path, 'ssh', '-p', 156 | '{}'.format(session['terminal_config']['port']), 157 | '{}@{}'.format(session['terminal_config']['username'], session['terminal_config']['domain'])) 158 | else: 159 | app.logger.debug("wrong term type {}".format(term_type)) 160 | disconnect() 161 | session['terminal_config'] = TERM_INIT_CONFIG 162 | else: 163 | # this is the parent process fork. 164 | # store child fd and pid in session 165 | # which means different visitor get different pid, fd, and its own room (by default) 166 | session['terminal_config']['fd'] = fd 167 | session['terminal_config']['child_pid'] = child_pid 168 | session['terminal_config']['room_id'] = rooms()[0] 169 | # in this article https://overiq.com/flask-101/sessions-in-flask/ 170 | # it said that if a mutable data structure need to be set in the flask session 171 | # we have to use session.modified = True to explicitly let flask know it 172 | session.modified = True 173 | set_winsize(fd, 50, 50) 174 | app.logger.debug("child pid = {}".format(child_pid)) 175 | app.logger.debug("rooms of this session = {}".format(rooms())) 176 | socketio.start_background_task(read_and_forward_pty_output, fd, child_pid, rooms()[0]) 177 | app.logger.debug("background task running") 178 | # print(session) 179 | 180 | 181 | @socketio.on('disconnect', namespace='/pty') 182 | def pty_disconnect(): 183 | try: 184 | child_process = psutil.Process(session.get('terminal_config', {}).get('child_pid')) 185 | except psutil.NoSuchProcess as err: 186 | disconnect() 187 | session['terminal_config'] = TERM_INIT_CONFIG 188 | return 189 | if child_process.status() in ('running', 'sleeping'): 190 | # if visitor just close the browser tab then left alone the pty here 191 | # it should be terminated by the parent process after 192 | child_process.terminate() 193 | app.logger.debug('user left the pty alone, terminated') 194 | app.logger.debug('Client disconnected') 195 | 196 | 197 | if __name__ == "__main__": 198 | socketio.run(app, host='0.0.0.0', debug=True, port=5000) 199 | -------------------------------------------------------------------------------- /config_sample.py: -------------------------------------------------------------------------------- 1 | TERM_INIT_CONFIG = { 2 | # instead of local server runnning this web terminal service 3 | # "domain" is the target that you want to access through local server (with this web terminal) 4 | # and before doing so - make sure you have username and port (on the "domain") to implement remote access 5 | 'domain': 'example.com', # or ip address like 192.168.10.11 6 | 'client_path': { 7 | 'telnet': '/usr/bin/telnet', # confirmed location of your client binary (with cmd like 'which telnet') 8 | 'ssh': '/usr/bin/ssh' 9 | } 10 | } -------------------------------------------------------------------------------- /flask_remote_term_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fisherworks/flask-remote-terminal/55b789e8540c8c04b4fbc9accaad9e505cb4aea0/flask_remote_term_chart.png -------------------------------------------------------------------------------- /flask_remote_term_demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fisherworks/flask-remote-terminal/55b789e8540c8c04b4fbc9accaad9e505cb4aea0/flask_remote_term_demo.jpg -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | web remote-only terminal 5 | 10 | 11 | 12 | 13 | 14 | web remote-only terminal    15 | status: connecting... 16 | 17 |
18 | 19 |

20 | developed by Fisherworks based on Chad Smith GitHub 21 |

22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Click==7.0 2 | Flask==1.1.2 3 | Flask-Session==0.3.1 4 | Flask-SocketIO==4.2.1 5 | gevent==1.4.0 6 | greenlet==0.4.15 7 | gunicorn==20.0.4 8 | itsdangerous==1.1.0 9 | Jinja2==2.11.3 10 | MarkupSafe==1.1.1 11 | psutil==5.7.0 12 | python-engineio==3.11.2 13 | python-socketio==4.4.0 14 | six==1.14.0 15 | Werkzeug==0.15.5 16 | --------------------------------------------------------------------------------