├── requirements.txt ├── .gitignore ├── README.md ├── package.json ├── index.html ├── emqxsl-ca.crt ├── app.js └── main.py /requirements.txt: -------------------------------------------------------------------------------- 1 | paho-mqtt==1.6.1 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules 3 | .vscode/ 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MQTT-Web-Terminal 2 | 3 | Bring any Linux device/server to the web, whenever they have public ip or not 4 | 5 | ## Config 6 | 7 | Modify app.js and main.py 8 | 9 | ## On Raspberry PI 10 | 11 | ```sh 12 | python3 -m venv .venv 13 | . .venv/bin/activatte 14 | pip install -r requirements.txt 15 | 16 | python3 main.py 17 | ``` 18 | 19 | ## On your PC 20 | 21 | ```sh 22 | npm install 23 | npm run test 24 | ``` 25 | 26 | ## Web Terminal 27 | 28 | open http://127.0.0.1:8000 29 | 30 | 31 | ## Reference 32 | 33 | * [pytermjs](https://github.com/cs01/pyxtermjs) 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "mqtt": "^4.3.7", 4 | "xterm": "^5.1.0", 5 | "xterm-addon-fit": "^0.7.0", 6 | "xterm-addon-search": "^0.11.0", 7 | "xterm-addon-web-links": "^0.8.0" 8 | }, 9 | "name": "mqtt-web-terminal", 10 | "version": "1.0.0", 11 | "main": "app.js", 12 | "devDependencies": {}, 13 | "scripts": { 14 | "test": "python3 -m http.server" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/lewangdev/MQTT-Web-Terminal.git" 19 | }, 20 | "keywords": [ 21 | "mqtt", 22 | "web-terminal" 23 | ], 24 | "author": "Le Wang", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/lewangdev/MQTT-Web-Terminal/issues" 28 | }, 29 | "homepage": "https://github.com/lewangdev/MQTT-Web-Terminal#readme" 30 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Web Terminal over MQTT 6 | 11 | 12 | 13 | 14 | 15 | Web Terminal over MQTT    17 |

MQTT Status: 18 | connecting... 19 |

20 | 21 |
22 | 23 |

24 | built by Le Wang 25 | GitHub 26 |

27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /emqxsl-ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh 3 | MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 4 | d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD 5 | QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT 6 | MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j 7 | b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG 8 | 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB 9 | CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 10 | nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt 11 | 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P 12 | T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 13 | gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO 14 | BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR 15 | TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw 16 | DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr 17 | hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg 18 | 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF 19 | PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls 20 | YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk 21 | CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= 22 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | (function (w) { 2 | const mqtt_user = "mqtt" 3 | const mqtt_passwd = "" 4 | const deviceId = "raspberrypi"; 5 | const mqttUrl = "wss://:8084/mqtt" 6 | 7 | const term = new Terminal({ 8 | cursorBlink: true, 9 | macOptionIsMeta: true, 10 | scrollback: true, 11 | }); 12 | term.attachCustomKeyEventHandler(customKeyEventHandler); 13 | // https://github.com/xtermjs/xterm.js/issues/2941 14 | const fit = new FitAddon.FitAddon(); 15 | term.loadAddon(fit); 16 | term.loadAddon(new WebLinksAddon.WebLinksAddon()); 17 | term.loadAddon(new SearchAddon.SearchAddon()); 18 | 19 | term.open(document.getElementById("terminal")); 20 | fit.fit(); 21 | term.resize(15, 50); 22 | console.log(`size: ${term.cols} columns, ${term.rows} rows`); 23 | fit.fit(); 24 | term.writeln("You can copy with ctrl+shift+x"); 25 | term.writeln("You can paste with ctrl+shift+v"); 26 | term.writeln('Press Enter key to activate the terminal') 27 | term.onData((data) => { 28 | console.log("browser terminal received new data:", data); 29 | var topicName = "/device/" + deviceId + "/terminal/input"; 30 | console.log(topicName) 31 | mqttc.publish(topicName, JSON.stringify({ input: data })); 32 | }); 33 | 34 | const mqttc = mqtt.connect(mqttUrl, { "username": mqtt_user, "password": mqtt_passwd }); 35 | mqttc.subscribe("/device/" + deviceId + "/terminal/output") 36 | const status = document.getElementById("status"); 37 | 38 | mqttc.on("message", function (topic, payload) { 39 | console.log(topic) 40 | if (topic == "/device/" + deviceId + "/terminal/output") { 41 | data = JSON.parse(payload); 42 | console.log("new output received from server:", data.output); 43 | term.write(data.output); 44 | } 45 | }); 46 | 47 | mqttc.on("connect", () => { 48 | fitToscreen(); 49 | status.innerHTML = 50 | 'connected'; 51 | }); 52 | 53 | mqttc.on("disconnect", () => { 54 | status.innerHTML = 55 | 'disconnected'; 56 | }); 57 | 58 | function fitToscreen() { 59 | fit.fit(); 60 | const dims = { cols: term.cols, rows: term.rows }; 61 | console.log("sending new dimensions to server's pty", dims); 62 | mqttc.publish("/device/" + deviceId + "/terminal/resize", JSON.stringify(dims)); 63 | } 64 | 65 | function debounce(func, wait_ms) { 66 | let timeout; 67 | return function (...args) { 68 | const context = this; 69 | clearTimeout(timeout); 70 | timeout = setTimeout(() => func.apply(context, args), wait_ms); 71 | }; 72 | } 73 | 74 | /** 75 | * Handle copy and paste events 76 | */ 77 | function customKeyEventHandler(e) { 78 | if (e.type !== "keydown") { 79 | return true; 80 | } 81 | if (e.ctrlKey && e.shiftKey) { 82 | const key = e.key.toLowerCase(); 83 | if (key === "v") { 84 | // ctrl+shift+v: paste whatever is in the clipboard 85 | navigator.clipboard.readText().then((toPaste) => { 86 | term.writeText(toPaste); 87 | }); 88 | return false; 89 | } else if (key === "c" || key === "x") { 90 | // ctrl+shift+x: copy whatever is highlighted to clipboard 91 | 92 | // 'x' is used as an alternate to 'c' because ctrl+c is taken 93 | // by the terminal (SIGINT) and ctrl+shift+c is taken by the browser 94 | // (open devtools). 95 | // I'm not aware of ctrl+shift+x being used by anything in the terminal 96 | // or browser 97 | const toCopy = term.getSelection(); 98 | navigator.clipboard.writeText(toCopy); 99 | term.focus(); 100 | return false; 101 | } 102 | } 103 | return true; 104 | } 105 | 106 | const wait_ms = 50; 107 | w.onresize = debounce(fitToscreen, wait_ms); 108 | 109 | })(window) 110 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import pty 2 | import os 3 | import subprocess 4 | import select 5 | import termios 6 | import struct 7 | import fcntl 8 | import logging 9 | import json 10 | 11 | import paho.mqtt.client as mqtt 12 | import threading 13 | 14 | 15 | FORMAT = '%(asctime)s %(message)s' 16 | logging.basicConfig(format=FORMAT, level=logging.DEBUG) 17 | 18 | DEVICE_ID = "raspberrypi" 19 | # DEVICE_SHELL = "bash" 20 | DEVICE_SHELL = "sh" 21 | MQTT_HOST = "" 22 | MQTT_PORT = 8883 23 | MQTT_USER = "mqtt" 24 | MQTT_PASSWD = "" 25 | MQTT_USE_TLS = True 26 | 27 | 28 | class AttrDict(dict): 29 | def __init__(self, *args, **kwargs): 30 | super(AttrDict, self).__init__(*args, **kwargs) 31 | self.__dict__ = self 32 | 33 | 34 | config = AttrDict(fd=None, cmd=DEVICE_SHELL, child_pid=None) 35 | 36 | 37 | def set_winsize(fd, row, col, xpix=0, ypix=0): 38 | logging.debug(f"Setting window size with termios: {row}x{col}") 39 | winsize = struct.pack("HHHH", row, col, xpix, ypix) 40 | fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) 41 | 42 | 43 | def resize(data): 44 | if config.fd: 45 | logging.debug(f"Resizing window to {data['rows']}x{data['cols']}") 46 | set_winsize(config.fd, data["rows"], data["cols"]) 47 | 48 | 49 | def read_and_forward_pty_output(mqttc): 50 | max_read_bytes = 1024 * 20 51 | while True: 52 | if config.fd: 53 | try: 54 | timeout_sec = None 55 | (data_ready, _, _) = select.select( 56 | [config.fd], [], [], timeout_sec) 57 | logging.debug("Data ready: " + 58 | ",".join(map(lambda x: str(x), data_ready))) 59 | except select.error: 60 | logging.info("select error") 61 | pass 62 | if data_ready: 63 | output = os.read(config.fd, max_read_bytes).decode( 64 | errors="ignore" 65 | ) 66 | mqttc.publish( 67 | f"/device/{DEVICE_ID}/terminal/output", json.dumps({"output": output})) 68 | 69 | 70 | def pty_input(data): 71 | """write to the child pty. The pty sees this as if you are typing in a real 72 | terminal. 73 | """ 74 | if config.fd: 75 | logging.debug("Received input from browser: %s" % data["input"]) 76 | os.write(config.fd, data["input"].encode()) 77 | 78 | 79 | def mqtt_on_connect(client, userdata, flags, rc): 80 | logging.debug(f"Connected with result code {rc}") 81 | 82 | # Subscribing in on_connect() means that if we lose the connection and 83 | # reconnect then subscriptions will be renewed. 84 | client.subscribe(f"/device/{DEVICE_ID}/terminal/input") 85 | client.subscribe(f"/device/{DEVICE_ID}/terminal/resize") 86 | t1 = threading.Thread(target=read_and_forward_pty_output, 87 | args=(client,), daemon=True) 88 | t1.start() 89 | 90 | 91 | # The callback for when a PUBLISH message is received from the server. 92 | def mqtt_on_message(client, userdata, msg): 93 | topic = msg.topic 94 | payload = msg.payload.decode('utf8') 95 | data = json.loads(payload) 96 | logging.debug(f"Topic: {msg.topic}, pyload: {payload}") 97 | if topic == f"/device/{DEVICE_ID}/terminal/input": 98 | pty_input(data) 99 | elif topic == f"/device/{DEVICE_ID}/terminal/resize": 100 | resize(data) 101 | 102 | 103 | if __name__ == "__main__": 104 | # create child process attached to a pty we can read from and write to 105 | (child_pid, fd) = pty.fork() 106 | if child_pid == 0: 107 | # this is the child process fork. 108 | # anything printed here will show up in the pty, including the output 109 | # of this subprocess 110 | while True: 111 | subprocess.run(config.cmd) 112 | else: 113 | # this is the parent process fork. 114 | # store child fd and pid 115 | config.fd = fd 116 | config.child_pid = child_pid 117 | set_winsize(fd, 50, 50) 118 | 119 | client = mqtt.Client() 120 | 121 | if MQTT_USE_TLS: 122 | client.tls_set("emqxsl-ca.crt") 123 | client.tls_insecure_set(True) 124 | client.username_pw_set( 125 | username=MQTT_USER, password=MQTT_PASSWD) 126 | client.on_connect = mqtt_on_connect 127 | client.on_message = mqtt_on_message 128 | 129 | client.connect(MQTT_HOST, MQTT_PORT, 60) 130 | 131 | # Blocking call that processes network traffic, dispatches callbacks and 132 | # handles reconnecting. 133 | # Other loop*() functions are available that give a threaded interface and a 134 | # manual interface. 135 | client.loop_forever() 136 | --------------------------------------------------------------------------------