├── 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 |
--------------------------------------------------------------------------------