#!/usr/bin/env python3 # This file is part of Remote Support Desktop # https://gitlab.das-netzwerkteam.de/RemoteWebApp/rwa.support.sessionservice # Copyright 2020, 2021 Jonathan Weth # Copyright 2020, 2021 Daniel Teichmann # Copyright 2020 Mike Gabriel # SPDX-License-Identifier: GPL-2.0-or-later # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the # Free Software Foundation, Inc., # 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import json import signal import time from threading import Thread from typing import Union import click import dbus import dbus.mainloop.glib import dbus.service from gi.repository import GLib from .config import user_settings from .lock import is_locked, lock, unlock from .session import Session from .trigger import TriggerServerThread from .log import logging ALLOW_ONLY_ONE_SESSION = True class RWASupportSessionService(dbus.service.Object): """D-Bus Session Service for RWA.Support. D-Bus namespace: ``org.ArcticaProject.RWASupportSessionService`` D-Bus object name: ``/RWASupportSessionService`` :param loop: GLib main loop running the service :param mockup_mode: Starts the service in mock up mode """ def __init__(self, loop: GLib.MainLoop, mockup_mode: bool = False, one_time: bool = False): self.loop = loop self.mockup_mode = mockup_mode self.one_time = one_time self.bus = dbus.SessionBus() name = dbus.service.BusName("org.ArcticaProject.RWASupportSessionService", bus=self.bus) self.check_lock_thread = Thread(target=self._check_lock) self.check_lock_thread.start() self.trigger_service = TriggerServerThread(self._trigger) self.trigger_service.start() self.update_service_running = False self.sessions = {} super().__init__(name, "/RWASupportSessionService") logging.info("D-Bus service has been started.") def _get_web_app_hosts(self) -> str: """Get all registered RWA.Support.WebApp hosts. Helper function: No D-Bus API. """ hosts = user_settings.web_app_hosts return json.dumps(hosts) @dbus.service.method("org.ArcticaProject.RWASupportSessionService", out_signature="s") def get_web_app_hosts(self) -> str: """Get all registered RWA.Support.WebApp hosts. :return: All registered hosts as JSON array (D-Bus string) **Structure of returned JSON:** :: ["https://example.org", "http://127.0.0.1:8000"] """ logging.info('D-Bus method call: %s()', 'get_web_app_hosts') logging.debug('Return to D-Bus caller: "%s"', self._get_web_app_hosts()) return self._get_web_app_hosts() @dbus.service.method( "org.ArcticaProject.RWASupportSessionService", in_signature="s", out_signature="s" ) def add_web_app_host(self, host: str) -> str: """Add a RWA.Support.WebApp host. :param host: Exact hostname of the RWA.Support.WebApp host (D-Bus string) :return: All registered hosts as JSON array (D-Bus string) **Structure of returned JSON:** :: ["https://example.org", "http://127.0.0.1:8000"] """ host = str(host) logging.info('D-Bus method call: %s("%s")', 'add_web_app_host', host) if self._is_url(host): user_settings.save_settings() user_settings.web_app_hosts.append(host) logging.debug('Added "%s" to "web_app_hosts" in user_settings', host) else: logging.warning('Given URL is not valid!') logging.debug('Didn\'t add "%s" to "web_app_hosts" in user_settings', host) return self._get_web_app_hosts() @dbus.service.method( "org.ArcticaProject.RWASupportSessionService", in_signature="i", out_signature="s" ) def remove_web_app_host(self, host_idx: int) -> str: """Remove a RWA.Support.WebApp host. :param idx: Index of web app host (D-Bus integer) :return: All registered hosts as JSON array (D-Bus string) **Structure of returned JSON:** :: ["https://example.org", "http://127.0.0.1:8000"] """ logging.info('D-Bus method call: %s(%d)',"remove_web_app_host", host_idx) hosts = json.loads(self._get_web_app_hosts()) if host_idx >= 0 and host_idx < len(hosts): del user_settings.web_app_hosts[host_idx] user_settings.save_settings() logging.debug('Removed web_app_hosts[%d]="%s" in user_settings', host_idx, hosts[host_idx]) else: logging.warning('Given host index is not valid!') logging.debug("Didn't remove web_app_hosts[%d] (not existant!) in " "user_settings", host_idx) return self._get_web_app_hosts() @dbus.service.method( "org.ArcticaProject.RWASupportSessionService", in_signature="i", out_signature="s" ) def start(self, host_idx: int) -> str: """Start a new remote session and register it in RWA.Support.WebApp. :param host_idx: Index of web app host (D-Bus integer) :return: Result as JSON (D-Bus string) **Structure of returned JSON (success):** :: {"status": "success", "id": , "url": "", "pin": } **Structure of returned JSON (error):** :: {"status": "error", "type": ""} **Possible choices for error types:** ``multiple``, ``connection``, ``host_not_found`` """ logging.info('D-Bus method call: %s(%d)', "start", host_idx) if ALLOW_ONLY_ONE_SESSION and len(self.sessions.values()) > 0: logging.warning( "There is already one session running and the service " "is configured to allow only one " "session, so this session won't be started." ) response = json.dumps({"status": "error", "type": "multiple"}) logging.debug("The response to the D-Bus caller: '%s'", response) return response try: host = user_settings.web_app_hosts[host_idx] logging.debug('web_app_hosts[%d] is the following host: "%s"', host_idx, host) except IndexError: logging.error("web_app_hosts[%d] does not exist!", host_idx) logging.debug("The response to the D-Bus caller: '%s'", response) response = json.dumps({"status": "error", "type": "host_not_found"}) return response # Start session try: session = Session(host, self.trigger_service.port, self.mockup_mode) # Add session to sessions list self.sessions[session.pid] = session # Start session update service self._ensure_update_service() return_json = session.client_meta return_json["status"] = "success" logging.info(f"New session #{session.pid} was started with meta {return_json}.") response = json.dumps(return_json) logging.debug("The response to the D-Bus caller: '%s'", response) return response except ConnectionError: logging.error("There was a connection error while trying to reach " "the RWA.Support.WebApp server.") response = json.dumps({"status": "error", "type": "connection"}) logging.debug("The response to the D-Bus caller: '%s'", response) return response @dbus.service.method( "org.ArcticaProject.RWASupportSessionService", in_signature="i", out_signature="s" ) def status(self, pid: int) -> str: """Return the status of a session. .. note:: This uses the last status version got by the update service in the background. :param pid: (Process) ID of session (D-Bus integer) :return: Session status as JSON (D-Bus string) **Structure of returned JSON:** :: {"id": , "status": } **Possible status options:** ============ ====================== ``running`` The session is running and ready for connecting. ``active`` The session is running and a the remote connected to the session. ``stopped`` The session was stopped. ``dead`` There was a problem, so that the session is dead. ============ ====================== """ logging.info('D-Bus method call: %s(%d)', "status", pid) response = self._get_status(pid) logging.debug("The response to the D-Bus caller: '%s'", response) return response @dbus.service.method( "org.ArcticaProject.RWASupportSessionService", in_signature="i", out_signature="s" ) def refresh_status(self, pid: int) -> str: """Update status from WebApp before returning it here like :meth:`status`.""" logging.info('D-Bus method call: %s(%d)', "refresh_status", pid) self._update_session(pid) response = self._get_status(pid) logging.debug("The response to the D-Bus caller: '%s'", response) return response @dbus.service.method( "org.ArcticaProject.RWASupportSessionService", in_signature="i", out_signature="s" ) def stop(self, pid: int) -> str: """Stop a remote session. :param pid: (Process) ID of session (D-Bus integer) :return: Session status as JSON (D-Bus string) **Structure of returned JSON:** :: {"id": , "status": "stopped"} """ logging.info('D-Bus method call: %s(%d)', "stop", pid) try: session = self.sessions[pid] except KeyError: response = json.dumps({"pid": pid, "status": "stopped"}, sort_keys=True) logging.debug("The response to the D-Bus caller: '%s'", response) return response session.stop() response = json.dumps({"id": pid, "status": "stopped"}, sort_keys=True) logging.debug("The response to the D-Bus caller: '%s'", response) return response def _get_status(self, pid: int) -> str: try: session = self.sessions[pid] except KeyError: return json.dumps({"id": pid, "status": "dead"}, sort_keys=True) return json.dumps(session.status) def _ensure_update_service(self): """Start session update thread if it isn't already running.""" if not self.update_service_running: self.update_thread = Thread(target=self._update_sessions) self.update_thread.start() def _update_session(self, pid: int): """Update the status of a session.""" try: session = self.sessions[pid] except KeyError: logging.info(f"Update status for session #{pid} …") logging.warning(" Session is dead.") return # Check if VNC process is still running running = session.vnc_process_running if running: pass elif session.status_text == "stopped" and session.pid in self.sessions: logging.info(f"Update status for session #{pid} …") logging.warning(" Session is dead.") del self.sessions[session.pid] else: logging.info(f"Update status for session #{pid} …") logging.warning(" VNC was stopped, so session is dead.") session.stop() del self.sessions[session.pid] def _update_sessions(self): """Go through all running sessions and update their status using ``_update_session``.""" logging.info("Started update service for sessions.") while len(self.sessions.values()) > 0: for session in list(self.sessions.values()): self._update_session(session.pid) time.sleep(2) self.update_service_running = False logging.info("Stopped update service for sessions.") if self.one_time: self._stop_all() def _trigger(self, session_id: int, data: dict, method: str = "trigger") -> Union[dict, bool]: """Trigger a specific session via trigger token.""" logging.info(f"Triggered with session ID {session_id} and {data}") for session in self.sessions.values(): if session.session_id == session_id: r = session.trigger(data, method) logging.info(f"Session #{session.pid} matches the ID: {r}") return r logging.warning(" No matching session found for this ID.") return False def _stop_all(self): """Stop all sessions.""" logging.info("Stop all sessions.") for session in list(self.sessions.values()): session.stop() del self.sessions[session.pid] def _stop_daemon(self): """Stop all sessions and this daemon.""" logging.info("Shut down session service.") self._stop_all() self.trigger_service.shutdown() self.loop.quit() def _check_lock(self): """Check if lock file exists.""" while True: if not is_locked(): logging.error("The lock file was removed, so stop this service.") self._stop_all() break time.sleep(1) def str2bool(v: Union[str, bool, int]) -> bool: """Return true or false if the given string can be interpreted as a boolean. If it fails, raise an exception. """ if isinstance(v, bool): return v if v.lower() in ("yes", "true", "t", "y", "1", 1): return True elif v.lower() in ("no", "false", "f", "n", "0", 0): return False else: raise argparse.ArgumentTypeError("Boolean value expected.") @click.command() @click.option( "-m", "--mockup", is_flag=True, default=False, help="Activates mock up mode. Acts like the real Session Service " "but don't do changes or call RWA.Support.WebApp.", ) @click.option( "-o", "--once", is_flag=True, default=False, help="Runs as one-time-service. Stops after one session.", ) def main(mockup, once): # Check for lock file if is_locked(): logging.error("The service is already running.") exit(1) # Create lock file lock() if mockup: logging.warning("All API responses are faked and should NOT BE USED IN PRODUCTION!") dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) loop = GLib.MainLoop() service_object = RWASupportSessionService(loop, mockup, once) def sigint_handler(sig, frame): logging.info("Service was terminated.") service_object._stop_daemon() def sigquit_handler(sig, frame): logging.info("Session was terminated.") service_object._stop_all() signal.signal(signal.SIGINT, sigint_handler) signal.signal(signal.SIGQUIT, sigquit_handler) loop.run() logging.info("Remove lock file ...") unlock() if __name__ == "__main__": main()