"""Outbound RDP/VNC sessions to other machines, driven from Home Assistant. The opposite direction from wayvnc: wayvnc is the *inbound* channel that lets a human control this thin client, this module is the thin client connecting *out* to a laptop or desktop elsewhere on the LAN. One client (Remmina) covers both protocols. Per the security note in mqtt_discovery.py, the payload from HA only ever selects a name out of the target list parsed from rdp-vnc.json. Host, port and protocol come from that file; a payload that does not match a known name is dropped. The generated .remmina profile is written to a path built from the target's index in that file, so even a target name full of slashes could not escape the profile directory. Credentials: the username (and Windows domain, if any) are read from a 0600 env file that is never committed. The password deliberately is not — see configs/remote-desktop/remote-desktop-credentials.env.example for why. """ from __future__ import annotations import logging import os import re import subprocess from dataclasses import dataclass from .runtime_state import STATE_DIR, TEMPLATE_DIR, ensure_runtime_copy, load_json from .sway_control import WS_WEB log = logging.getLogger(__name__) CONFIG_FILENAME = "rdp-vnc.json" CREDENTIALS_PATH = os.path.join(TEMPLATE_DIR, "remote-desktop-credentials.env") PROFILE_DIR = os.path.join(STATE_DIR, "remmina") PROTOCOLS = {"rdp": "RDP", "vnc": "VNC"} DEFAULT_PORTS = {"rdp": 3389, "vnc": 5900} REMMINA_APP_ID = "org.remmina.Remmina" @dataclass(frozen=True) class Target: name: str host: str port: int protocol: str credentials_ref: str profile_path: str def _read_credentials() -> dict[str, str]: values: dict[str, str] = {} try: with open(CREDENTIALS_PATH, encoding="utf-8") as handle: for line in handle: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") values[key.strip().upper()] = value.strip().strip('"').strip("'") except OSError: log.info( "%s not present; remote-desktop profiles will be generated without a username", CREDENTIALS_PATH, ) return values class RemoteDesktop: def __init__(self, sway): self.sway = sway self.config_path = ensure_runtime_copy(CONFIG_FILENAME) self.targets: dict[str, Target] = {} self.current: str | None = None self.load() def load(self) -> None: config = load_json(self.config_path) entries = config.get("targets") if not isinstance(entries, list): log.warning("%s has no 'targets' list; no remote-desktop entities", self.config_path) entries = [] credentials = _read_credentials() targets: dict[str, Target] = {} for index, entry in enumerate(entries): if not isinstance(entry, dict): continue name = str(entry.get("name") or "").strip() host = str(entry.get("host") or "").strip() protocol = str(entry.get("protocol") or "").strip().lower() if not name or not host: log.warning("skipping remote-desktop target %s: needs a name and a host", index) continue if protocol not in PROTOCOLS: log.warning( "skipping remote-desktop target %r: protocol must be one of %s", name, ", ".join(sorted(PROTOCOLS)), ) continue if name in targets: log.warning("skipping duplicate remote-desktop target %r", name) continue try: port = int(entry.get("port") or DEFAULT_PORTS[protocol]) except (TypeError, ValueError): port = DEFAULT_PORTS[protocol] target = Target( name=name, host=host, port=port, protocol=protocol, credentials_ref=str(entry.get("credentials_ref") or "").strip(), # Indexed, not named: the filename must not be derived from a string a # human typed into a config file, and the index is already unique. profile_path=os.path.join(PROFILE_DIR, f"target-{index}.remmina"), ) targets[name] = target self._write_profile(target, credentials) self.targets = targets if self.current not in self.targets: self.current = next(iter(self.targets), None) log.info("loaded %d remote-desktop target(s)", len(self.targets)) def options(self) -> list[str]: return list(self.targets) def _write_profile(self, target: Target, credentials: dict[str, str]) -> None: prefix = re.sub(r"[^A-Z0-9]", "_", target.credentials_ref.upper()) username = credentials.get(f"{prefix}_USERNAME", "") if prefix else "" domain = credentials.get(f"{prefix}_DOMAIN", "") if prefix else "" # No `password=` key. Remmina asks once and keeps it in its own store; writing # one here would mean a cleartext credential for another machine sitting on an # unattended kiosk. profile = "\n".join( ( "[remmina]", f"name={target.name}", f"protocol={PROTOCOLS[target.protocol]}", f"server={target.host}:{target.port}", f"username={username}", f"domain={domain}", "group=SmartestHome", "window_maximize=1", "viewmode=1", "scale=1", "disableclipboard=0", "", ) ) try: os.makedirs(PROFILE_DIR, exist_ok=True) with open(target.profile_path, "w", encoding="utf-8") as handle: handle.write(profile) os.chmod(target.profile_path, 0o600) except OSError as exc: log.warning("could not write %s: %s", target.profile_path, exc) # --- entity surface ----------------------------------------------------- def select(self, payload: str) -> str | None: name = payload.strip() if name not in self.targets: log.warning("ignoring unknown remote-desktop target %r", name) return self.current self.current = name log.info("remote-desktop target set to %s", name) return self.current def connect(self, _payload: str = "") -> None: if self.current is None: log.warning("no remote-desktop target selected; nothing to connect to") return target = self.targets[self.current] log.info("connecting to %s (%s %s:%s)", target.name, target.protocol, target.host, target.port) # No process_pattern: Remmina is single-instance, and `remmina -c` against a # running instance opens the connection there. Re-running it is how you switch # target, so "already running, just focus" would be wrong. self.sway.launch_app(["remmina", "-c", target.profile_path], workspace=WS_WEB) self.sway.focus_window(f'app_id="{REMMINA_APP_ID}"') def disconnect(self, _payload: str = "") -> None: # A fixed argv, not a pattern built from anything inbound. -x matches the exact # process name so a stray "remmina" substring elsewhere cannot be caught. log.info("disconnecting remote desktop") try: subprocess.run( ["pkill", "-u", str(os.getuid()), "-x", "remmina"], capture_output=True, timeout=5, check=False, ) except (OSError, subprocess.TimeoutExpired) as exc: log.warning("could not stop remmina: %s", exc)