Skip to content

Commit 5bf7a48

Browse files
committed
fix(www): switch from flask to tornado
Move the www interface to its own process and with tornada as the engine vs flask to improve performance. This architecture change also allows the www code to unprivileged. Signed-off-by: Cedric Hombourger <[email protected]>
1 parent d3e0877 commit 5bf7a48

24 files changed

+854
-478
lines changed

debian/control

+2-2
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ Package: mtda-www
124124
Architecture: all
125125
Multi-Arch: foreign
126126
Depends: mtda-service,
127-
python3-flask-socketio,
128-
python3-gevent-websocket,
127+
python3-systemd,
128+
python3-tornado,
129129
novnc,
130130
websockify
131131
Description: web-based user-interface for MTDA

debian/mtda-service.install

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
debian/mtda.service lib/systemd/system/
2+
debian/mtda-config.path lib/systemd/system/
3+
debian/mtda-config.service lib/systemd/system/

debian/mtda-service.postinst

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ case "$1" in
1010
if ! grep -qe '^libcomposite$' /etc/modules; then
1111
echo "libcomposite" >/etc/modules || exit ${?}
1212
fi
13+
if ! getent passwd mtda >/dev/null; then
14+
adduser --system --group --no-create-home --disabled-login mtda
15+
fi
1316
;;
1417

1518
esac

debian/mtda-www.install

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
debian/mtda-www.service lib/systemd/system/

debian/mtda-www.postinst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/sh
2+
3+
#DEBHELPER#

debian/mtda-www.service

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[Unit]
2+
Description=mtda-www service
3+
Requires=mtda.service
4+
After=mtda.service
5+
6+
[Service]
7+
Environment=HOST=localhost PORT=9080
8+
ExecStart=/usr/libexec/mtda/www --host $HOST --port $PORT
9+
Restart=always
10+
StandardOutput=journal
11+
StandardError=journal
12+
User=mtda
13+
Group=mtda
14+
PrivateTmp=true
15+
ProtectSystem=strict
16+
17+
[Install]
18+
WantedBy=multi-user.target

debian/mtda-wwww.install

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
debian/mtda-www.service lib/systemd/mtda-www.service
2+
debian/mtda-www.socket lib/systemd/mtda-www.socket

debian/rules

+6-3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ override_dh_auto_install:
3131
rm -rf debian/mtda-pytest
3232
rm -rf debian/mtda-service
3333
rm -rf debian/mtda-ui
34+
rm -rf debian/mtda-www
3435
# lintian package-installs-python-pycache-dir
3536
find debian -name "__pycache__" -type f -delete
3637
:
@@ -78,11 +79,13 @@ override_dh_auto_install:
7879
:
7980
install -m 0755 -d debian/mtda-www$(MTDA_DIST)/
8081
install -m 0755 -d debian/mtda-www/etc/mtda/config.d/
82+
install -m 0755 -d debian/mtda-www/usr/libexec/mtda/
8183
mv debian/mtda-service$(MTDA_DIST)/assets debian/mtda-www$(MTDA_DIST)/
8284
mv debian/mtda-service$(MTDA_DIST)/templates debian/mtda-www$(MTDA_DIST)/
83-
mv debian/mtda-service$(MTDA_DIST)/www.py debian/mtda-www$(MTDA_DIST)/
85+
mv debian/mtda-service/usr/bin/mtda-www debian/mtda-www/usr/libexec/mtda/www
8486
install -m 0644 configs/10-www.conf debian/mtda-www/etc/mtda/config.d/
8587

8688
override_dh_installsystemd:
87-
dh_installsystemd --name=mtda
88-
dh_installsystemd --name=mtda-config
89+
dh_installsystemd -p mtda-www --name=mtda-www
90+
dh_installsystemd -p mtda-service --name=mtda
91+
dh_installsystemd -p mtda-service --name=mtda-config

meta-isar/recipes-python/mtda/mtda_git.bb

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ MTDA_FILES = " \
2323
mtda-service \
2424
mtda-systemd-helper \
2525
mtda-ui \
26+
mtda-www \
2627
mtda.ini \
2728
mtda/ \
2829
scripts/ \

mtda-www

+310
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
#!/usr/bin/env python3
2+
# ---------------------------------------------------------------------------
3+
# Web service for MTDA
4+
# ---------------------------------------------------------------------------
5+
#
6+
# This software is a part of MTDA.
7+
# Copyright (C) 2025 Siemens AG
8+
#
9+
# ---------------------------------------------------------------------------
10+
# SPDX-License-Identifier: MIT
11+
# ---------------------------------------------------------------------------
12+
13+
import argparse
14+
import asyncio
15+
import base64
16+
import json
17+
import tornado.web
18+
import tornado.websocket
19+
import tornado.ioloop
20+
import tornado.escape
21+
import os
22+
import uuid
23+
import zmq.asyncio
24+
25+
from mtda.client import Client
26+
import mtda.constants as CONSTS
27+
from mtda.console.remote import RemoteConsole
28+
from mtda.console.screen import ScreenOutput
29+
30+
31+
class MainHandler(tornado.web.RequestHandler):
32+
def get(self):
33+
self.render("index.html")
34+
35+
36+
class AssetsHandler(tornado.web.StaticFileHandler):
37+
pass
38+
39+
40+
class NoVNCHandler(tornado.web.StaticFileHandler):
41+
pass
42+
43+
44+
class WebSocketHandler(tornado.websocket.WebSocketHandler):
45+
clients = set()
46+
47+
def open(self):
48+
self.session_id = uuid.uuid4().hex
49+
self.set_nodelay(True)
50+
WebSocketHandler.clients.add(self)
51+
mtda = self.application.settings['mtda']
52+
if mtda is not None:
53+
self.write_message(
54+
{"session": {"id": self.session_id}}
55+
)
56+
self.write_message(
57+
{"mtda-version": {"version": mtda.agent_version()}}
58+
)
59+
self.write_message(
60+
{"console-output": {"output": mtda.console_dump()}}
61+
)
62+
self.write_message(
63+
{"POWER": {"event": mtda.target_status()}}
64+
)
65+
status, _, _ = mtda.storage_status()
66+
self.write_message({"STORAGE": {"event": status}})
67+
68+
fmt = mtda.video_format()
69+
if fmt is not None:
70+
url = mtda.video_url(host=self.request.host)
71+
self.write_message({"video-info": {"format": fmt, "url": url}})
72+
73+
def on_message(self, message):
74+
mtda = self.application.settings['mtda']
75+
if mtda is not None:
76+
sid = self.session_id
77+
if isinstance(message, bytes):
78+
mtda.debug(4, f"www.ws.on_message({len(message)} bytes, "
79+
f"session={sid})")
80+
sockets = self.application.settings['sockets']
81+
if sid in sockets:
82+
sockets[sid].send(message)
83+
else:
84+
mtda.debug(1, f'no data socket for session {sid}!')
85+
else:
86+
data = tornado.escape.json_decode(message)
87+
mtda.debug(4, f"www.ws.on_message({data})")
88+
if 'console-input' in data:
89+
input = data['console-input']['input']
90+
mtda.console_send(input, raw=False, session=sid)
91+
92+
def on_close(self):
93+
WebSocketHandler.clients.remove(self)
94+
95+
96+
class BaseHandler(tornado.web.RequestHandler):
97+
def result_as_json(self, result):
98+
response = {"result": result}
99+
self.set_header("Content-Type", "application/json")
100+
self.write(json.dumps(response))
101+
102+
103+
class KeyboardInputHandler(BaseHandler):
104+
def get(self):
105+
mtda = self.application.settings['mtda']
106+
result = ''
107+
input_key = self.get_argument("input", "")
108+
109+
key_map = {
110+
"esc": "<esc>",
111+
"f1": "<f1>", "f2": "<f2>", "f3": "<f3>", "f4": "<f4>",
112+
"f5": "<f5>", "f6": "<f6>", "f7": "<f7>", "f8": "<f8>",
113+
"f9": "<f9>", "f10": "<f10>", "f11": "<f11>", "f12": "<12>",
114+
"\b": "<backspace>", " ": "<tab>", "caps": "<capslock>",
115+
"\n": "<enter>",
116+
"left": "<left>", "right": "<right>",
117+
"up": "<up>", "down": "<down>"
118+
}
119+
if input_key in key_map:
120+
input_key = key_map[input_key]
121+
122+
result = mtda.keyboard_press(
123+
input_key,
124+
ctrl=self.get_argument('ctrl', 'false') == 'true',
125+
shift=self.get_argument('shift', 'false') == 'true',
126+
alt=self.get_argument('alt', 'false') == 'true',
127+
meta=self.get_argument('meta', 'false') == 'true'
128+
)
129+
self.result_as_json({"result": result})
130+
131+
132+
class PowerToggleHandler(BaseHandler):
133+
def get(self):
134+
mtda = self.application.settings['mtda']
135+
result = ''
136+
if mtda is not None:
137+
sid = self.get_argument('session')
138+
result = mtda.target_toggle(session=sid)
139+
self.result_as_json({"result": result})
140+
141+
142+
class StorageFlushHandler(BaseHandler):
143+
def get(self):
144+
mtda = self.application.settings['mtda']
145+
result = ''
146+
if mtda is not None:
147+
size = int(self.get_argument('size'))
148+
sid = self.get_argument('session')
149+
result = mtda.storage_flush(size=size, session=sid)
150+
self.result_as_json({"result": result})
151+
152+
153+
class StorageOpenHandler(BaseHandler):
154+
def get(self):
155+
mtda = self.application.settings['mtda']
156+
result = ''
157+
if mtda is not None:
158+
sid = self.get_argument('session')
159+
zmq_socket = mtda.storage_open(session=sid)
160+
self.application.settings['sockets'][sid] = zmq_socket
161+
self.result_as_json({"result": result})
162+
163+
164+
class StorageCloseHandler(BaseHandler):
165+
def get(self):
166+
mtda = self.application.settings['mtda']
167+
result = ''
168+
if mtda is not None:
169+
sid = self.get_argument('session')
170+
result = mtda.storage_close(session=sid)
171+
del self.application.settings['sockets'][sid]
172+
self.result_as_json({"result": result})
173+
174+
175+
class StorageToggleHandler(tornado.web.RequestHandler):
176+
def get(self):
177+
mtda = self.application.settings['mtda']
178+
result = ''
179+
if mtda is not None:
180+
sid = self.get_argument('session')
181+
status, _, _ = mtda.storage_status(session=sid)
182+
if status == CONSTS.STORAGE.ON_HOST:
183+
result = (
184+
'TARGET'
185+
if mtda.storage_to_target()
186+
else 'HOST'
187+
)
188+
elif status == CONSTS.STORAGE.ON_TARGET:
189+
result = (
190+
'HOST'
191+
if mtda.storage_to_host()
192+
else 'TARGET'
193+
)
194+
self.write(result)
195+
196+
197+
class WebConsole(RemoteConsole):
198+
def _context(self):
199+
return zmq.asyncio.Context()
200+
201+
async def reader(self):
202+
self.connect()
203+
try:
204+
while self.exiting is False:
205+
topic, data = await self.socket.recv_multipart()
206+
self.dispatch(topic, data)
207+
except zmq.error.ContextTerminated:
208+
self.socket = None
209+
210+
211+
class WebMonitor(WebConsole):
212+
def __init__(self, host, port, screen):
213+
super().__init__(host, port, screen)
214+
self.topic = CONSTS.CHANNEL.MONITOR
215+
216+
def _subscribe(self):
217+
super()._subscribe()
218+
self.socket.setsockopt(zmq.SUBSCRIBE, CONSTS.CHANNEL.EVENTS)
219+
220+
221+
class WebOutput(ScreenOutput):
222+
def __init__(self, application, mtda):
223+
self.application = application
224+
super().__init__(mtda)
225+
226+
def _send_to_clients(self, message):
227+
for client in WebSocketHandler.clients:
228+
client.write_message(message)
229+
230+
def on_event(self, event):
231+
info = event.split()
232+
domain = info[0]
233+
234+
if domain == 'SESSION':
235+
self.session_event(info[1:])
236+
237+
message = {domain: {"event": ' '.join(info[1:])}}
238+
loop = tornado.ioloop.IOLoop.current()
239+
loop.add_callback(self._send_to_clients, message)
240+
241+
def session_event(self, info):
242+
if info[0] == 'INACTIVE':
243+
sid = info[1]
244+
del self.application.settings['sockets'][sid]
245+
246+
def write(self, data):
247+
data = base64.b64encode(data).decode('utf-8')
248+
message = {"console-output": {"output": data}}
249+
loop = tornado.ioloop.IOLoop.current()
250+
loop.add_callback(self._send_to_clients, message)
251+
252+
253+
class Service:
254+
def __init__(self):
255+
self.mtda = Client('localhost')
256+
257+
def parse_args(self):
258+
parser = argparse.ArgumentParser(description='mtda.www settings')
259+
parser.add_argument(
260+
"--host",
261+
type=str,
262+
default=CONSTS.DEFAULTS.WWW_HOST)
263+
parser.add_argument(
264+
"--port",
265+
type=int,
266+
default=CONSTS.DEFAULTS.WWW_PORT)
267+
args = parser.parse_args()
268+
self._host = args.host
269+
self._port = args.port
270+
271+
async def run(self):
272+
self.parse_args()
273+
BASE_DIR = os.path.dirname(os.path.abspath(CONSTS.__file__))
274+
self.application = tornado.web.Application([
275+
(r"/", MainHandler),
276+
(r"/assets/(.*)", AssetsHandler, {
277+
"path": os.path.join(BASE_DIR, "assets")
278+
}),
279+
(r"/novnc/(.*)", NoVNCHandler, {
280+
"path": "/usr/share/novnc"
281+
}),
282+
(r"/mtda", WebSocketHandler),
283+
(r"/keyboard-input", KeyboardInputHandler),
284+
(r"/power-toggle", PowerToggleHandler),
285+
(r"/storage-close", StorageCloseHandler),
286+
(r"/storage-flush", StorageFlushHandler),
287+
(r"/storage-open", StorageOpenHandler),
288+
(r"/storage-toggle", StorageToggleHandler),
289+
], template_path=os.path.join(BASE_DIR, "templates"),
290+
mtda=self.mtda, sockets={}, debug=False)
291+
292+
output = WebOutput(self.application, self.mtda)
293+
conport = self.mtda.console_port()
294+
remote = self.mtda.remote()
295+
296+
# Connect both the console and monitor to our custom output
297+
console = WebConsole(remote, conport, output)
298+
asyncio.create_task(console.reader())
299+
monitor = WebMonitor(remote, conport, output)
300+
asyncio.create_task(monitor.reader())
301+
302+
self.mtda.start()
303+
self.application.listen(self._port, self._host)
304+
305+
await asyncio.Event().wait()
306+
307+
308+
if __name__ == '__main__':
309+
srv = Service()
310+
asyncio.run(srv.run())

0 commit comments

Comments
 (0)