Skip to content

MTDA-TV + preliminary hardening - #607

Merged
chombourger merged 12 commits into
siemens:nextfrom
chombourger:ch/feat/mtda-tv
Aug 25, 2026
Merged

MTDA-TV + preliminary hardening#607
chombourger merged 12 commits into
siemens:nextfrom
chombourger:ch/feat/mtda-tv

Conversation

@chombourger

Copy link
Copy Markdown
Collaborator
image

chombourger and others added 3 commits August 20, 2026 14:52
Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
_GrpcImpl.storage_open() ignored the session kwarg passed by callers
(e.g. mtda-www's per-request Client instance) and always sent its own
default/generated session as gRPC metadata instead. This caused the
server to open storage and fire the OPENED event under a session id
the browser never saw, so the client's OPENED-session check in
index.html never matched and no upload data was ever sent (silent
0-byte timeout).

Client.__getattr__'s generic wrapper had the same issue in the other
direction: it unconditionally overwrote any explicitly-passed session
kwarg with the client's own default, instead of only filling it in
when missing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
Several URLs in the per-agent web UI were hardcoded as absolute-root
paths (the /mtda WebSocket, pyodide wheel/console URLs, the swagger
doc URL). These break whenever the page is served under a path
prefix by a reverse proxy (e.g. Traefik with mtda-tv's gateway
support routing /agents/<name>/), since the browser computes these
URLs from window.location and has no notion of what prefix the proxy
stripped.

Introduce window.MTDA_BASE, derived from the page's own path, and use
it to build these URLs relative to the page instead of the origin
root. Also respect https:/wss: when building the WebSocket URL.

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
@chombourger chombourger added check and removed check labels Aug 20, 2026
@chombourger chombourger changed the title MTDA-TV Grab them all... MTDA-TV + preliminary hardening Aug 20, 2026
@chombourger chombourger removed the check label Aug 20, 2026
@chombourger chombourger added check and removed check labels Aug 20, 2026
@chombourger chombourger added check and removed check labels Aug 20, 2026
Add mtda-tv, a Tornado service that discovers MTDA agents via
ZeroConf, polls video snapshots over gRPC, and presents them in a
TV-style grid, redirecting to each agent's mtda-www on click.

Add an AgentInfo RPC exposing agent metadata (version, name,
www_port) needed by mtda-tv.

Add transparent gateway/proxy support: a [gateway] config section
lets mtda-tv act as a dynamic discovery source for a reverse proxy
(e.g. Traefik), so agents are reached through a single hostname
instead of each agent's own mtda-www instance. Includes a
/api/traefik-config endpoint, a gateway_url per agent in /api/agents,
and an optional mtda-tv-gateway Debian package with sample Traefik
config and a postinst to restart mtda-tv on (re)configuration. A
docker-compose.yml is included alongside the sample Traefik config so
Traefik keeps running (restart: unless-stopped) across crashes and
machine reboots.

A new base_url gateway option lets tile links keep pointing at the
reverse proxy even when the grid page is browsed directly on
mtda-tv's own port instead of through the proxy.

Debian packaging is reworked to share resources between mtda-tv and
mtda-www.

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
…efik

The gRPC control channel had no auth/TLS (CWE-306/CWE-862/CWE-319): a
client-supplied 'mtda-session' string was trusted as-is, letting anyone
forge a session identity and call privileged RPCs (power, console,
storage, USB, ...) with no credentials. Opt-in, defaults to prior
(insecure) behavior.

- mtda/tls.py: mTLS-aware client channels (mtdas:// scheme), server
  credentials, -r/$MTDA_REMOTE parsing, and grpc.aio channel support.
- mtda/grpc/servicer.py: when [security] trust_proxy_identity is set,
  derive the session identity from a reverse-proxy-verified client
  certificate CN (e.g. Traefik's passTLSClientCert) instead of the
  spoofable 'mtda-session' metadata, through the shared _session()
  choke point used by all RPCs.
- mtda-service: configurable bind address and TLS/mTLS termination
  ([security] bind, server_tls) for the separate-node deployment case;
  mDNS advertisement now respects the configured bind address.
- mtda-cli/mtda/client.py/mtda/console/remote.py: client-side TLS/mTLS
  support so mtda-cli/consoles can reach an agent through Traefik.
- mtda-tv/mtda-www: TLS-aware gRPC clients ([security] client settings,
  build_channel()/build_aio_channel()); new [gateway] tls_entry_point
  option so a single agent can be reachable via both plain and TLS
  Traefik entry points.
- configs/, docs/hardening.rst: sample Traefik/mTLS configs and a full
  hardening guide (threat model, deployment shapes, client setup,
  firewalling, production Traefik deployment under systemd).

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
Document MTDA's intended security model (trusted/controlled
environments only, not a security boundary for untrusted networks),
deployment assumptions, vulnerability reporting process, and point to
docs/hardening.rst for the optional TLS/mTLS hardening now available.

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
The Command RPC forwarded arbitrary caller-supplied strings straight
into QEMU's HMP monitor, allowing any authenticated client to read/
write host files or run host processes (e.g. via pmemsave, migrate
exec:). This authorization gap is independent of the mTLS/identity
hardening added earlier, which only verifies who is calling, not
what they can do.

No other backend implemented Command beyond a no-op stub, and the
only documented use (hostfwd_add) has no other caller. Remove the
RPC, client/agent/servicer plumbing, all power backend stubs, and
the mtda-cli command subcommand. mtda-cli monitor send/wait remains
as the supported way to interact with the QEMU monitor.

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
Subscribe was the only RPC that never extracted the caller's session
identity, so it streamed all console/monitor bytes and events to any
client able to reach the gRPC endpoint, bypassing the identity check
every other RPC performs. When trust_proxy_identity (mTLS hardening)
is enabled, reject subscribers with no verified client identity;
otherwise track the session like every other call for consistency.

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
console_locked/power_locked/_check_locked all actually answer 'does
this session hold the exclusive lock', not 'is this locked out of
the session'. Rename to console_is_owner/power_is_owner/
_is_lock_owner to remove the ambiguity before fixing how callers use
this value.

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
…ower/storage

Every consumer of console_is_owner/power_is_owner used it as an
'is locked' style gate (proceed only when the check is False), but
the check actually returns True precisely when the caller IS the
lock owner. As a result the lock owner was blocked from their own
console/monitor input, power control and storage swap while any
other, non-owning session was let through unrestricted - the exact
opposite of what target_lock is supposed to enforce.

Add _locked_out(session), which correctly returns True only when the
device is locked by a *different* session, and switch every affected
call site (console clear/dump/flush/prompt/run/send/tail/wait,
monitor send/wait, power on/off/toggle/firmware, storage_locked) to
use it.

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
keyboard_press, keyboard_write and mouse_move had no lock check at
all, so any session could type or move the mouse on a target that
another session holds an exclusive lock on. Gate them the same way
as console/monitor input: proceed only when the target is unlocked
or the caller holds the lock.

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
/storage-set-bmap (mtda-www) and the client's bmap auto-discovery
both parsed uploaded/local bmap files with plain
xml.etree.ElementTree.fromstring(), which does not limit expat's
internal entity expansion.

Add utils.SafeXml.fromstring(), built directly on xml.parsers.expat,
which rejects any DOCTYPE declaration outright. Since custom entities
can only be declared from a DOCTYPE's internal subset, this blocks
entity-expansion DoS with no new dependency, while still parsing
well-formed bmap files that never use DOCTYPE. Use it in both
mtda-www and mtda/client.py instead of the raw ElementTree call.

Signed-off-by: Cedric Hombourger <cedric.hombourger@siemens.com>
@chombourger
chombourger changed the base branch from main to next August 21, 2026 04:06
@chombourger
chombourger requested a review from baprusty August 24, 2026 13:53
@baprusty

Copy link
Copy Markdown
Collaborator

Great addition. LGTM, thanks

@chombourger
chombourger merged commit 88aa008 into siemens:next Aug 25, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants