1#!/usr/bin/python
2# Copyright (c) 2012 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6# Virtual Me2Me implementation.  This script runs and manages the processes
7# required for a Virtual Me2Me desktop, which are: X server, X desktop
8# session, and Host process.
9# This script is intended to run continuously as a background daemon
10# process, running under an ordinary (non-root) user account.
11
12import atexit
13import errno
14import fcntl
15import getpass
16import grp
17import hashlib
18import json
19import logging
20import optparse
21import os
22import pipes
23import platform
24import psutil
25import platform
26import signal
27import socket
28import subprocess
29import sys
30import tempfile
31import time
32import uuid
33
34LOG_FILE_ENV_VAR = "CHROME_REMOTE_DESKTOP_LOG_FILE"
35
36# This script has a sensible default for the initial and maximum desktop size,
37# which can be overridden either on the command-line, or via a comma-separated
38# list of sizes in this environment variable.
39DEFAULT_SIZES_ENV_VAR = "CHROME_REMOTE_DESKTOP_DEFAULT_DESKTOP_SIZES"
40
41# By default, provide a maximum size that is large enough to support clients
42# with large or multiple monitors. This is a comma-separated list of
43# resolutions that will be made available if the X server supports RANDR. These
44# defaults can be overridden in ~/.profile.
45DEFAULT_SIZES = "1600x1200,3840x2560"
46
47# If RANDR is not available, use a smaller default size. Only a single
48# resolution is supported in this case.
49DEFAULT_SIZE_NO_RANDR = "1600x1200"
50
51SCRIPT_PATH = sys.path[0]
52
53IS_INSTALLED = (os.path.basename(sys.argv[0]) != 'linux_me2me_host.py')
54
55if IS_INSTALLED:
56  HOST_BINARY_NAME = "chrome-remote-desktop-host"
57else:
58  HOST_BINARY_NAME = "remoting_me2me_host"
59
60CHROME_REMOTING_GROUP_NAME = "chrome-remote-desktop"
61
62HOME_DIR = os.environ["HOME"]
63CONFIG_DIR = os.path.join(HOME_DIR, ".config/chrome-remote-desktop")
64SESSION_FILE_PATH = os.path.join(HOME_DIR, ".chrome-remote-desktop-session")
65SYSTEM_SESSION_FILE_PATH = "/etc/chrome-remote-desktop-session"
66
67X_LOCK_FILE_TEMPLATE = "/tmp/.X%d-lock"
68FIRST_X_DISPLAY_NUMBER = 20
69
70# Amount of time to wait between relaunching processes.
71SHORT_BACKOFF_TIME = 5
72LONG_BACKOFF_TIME = 60
73
74# How long a process must run in order not to be counted against the restart
75# thresholds.
76MINIMUM_PROCESS_LIFETIME = 60
77
78# Thresholds for switching from fast- to slow-restart and for giving up
79# trying to restart entirely.
80SHORT_BACKOFF_THRESHOLD = 5
81MAX_LAUNCH_FAILURES = SHORT_BACKOFF_THRESHOLD + 10
82
83# Globals needed by the atexit cleanup() handler.
84g_desktops = []
85g_host_hash = hashlib.md5(socket.gethostname()).hexdigest()
86
87
88def is_supported_platform():
89  # Always assume that the system is supported if the config directory or
90  # session file exist.
91  if (os.path.isdir(CONFIG_DIR) or os.path.isfile(SESSION_FILE_PATH) or
92      os.path.isfile(SYSTEM_SESSION_FILE_PATH)):
93    return True
94
95  # The host has been tested only on Ubuntu.
96  distribution = platform.linux_distribution()
97  return (distribution[0]).lower() == 'ubuntu'
98
99
100def get_randr_supporting_x_server():
101  """Returns a path to an X server that supports the RANDR extension, if this
102  is found on the system. Otherwise returns None."""
103  try:
104    xvfb = "/usr/bin/Xvfb-randr"
105    if not os.path.exists(xvfb):
106      xvfb = locate_executable("Xvfb-randr")
107    return xvfb
108  except Exception:
109    return None
110
111
112class Config:
113  def __init__(self, path):
114    self.path = path
115    self.data = {}
116    self.changed = False
117
118  def load(self):
119    """Loads the config from file.
120
121    Raises:
122      IOError: Error reading data
123      ValueError: Error parsing JSON
124    """
125    settings_file = open(self.path, 'r')
126    self.data = json.load(settings_file)
127    self.changed = False
128    settings_file.close()
129
130  def save(self):
131    """Saves the config to file.
132
133    Raises:
134      IOError: Error writing data
135      TypeError: Error serialising JSON
136    """
137    if not self.changed:
138      return
139    old_umask = os.umask(0066)
140    try:
141      settings_file = open(self.path, 'w')
142      settings_file.write(json.dumps(self.data, indent=2))
143      settings_file.close()
144      self.changed = False
145    finally:
146      os.umask(old_umask)
147
148  def save_and_log_errors(self):
149    """Calls self.save(), trapping and logging any errors."""
150    try:
151      self.save()
152    except (IOError, TypeError) as e:
153      logging.error("Failed to save config: " + str(e))
154
155  def get(self, key):
156    return self.data.get(key)
157
158  def __getitem__(self, key):
159    return self.data[key]
160
161  def __setitem__(self, key, value):
162    self.data[key] = value
163    self.changed = True
164
165  def clear(self):
166    self.data = {}
167    self.changed = True
168
169
170class Authentication:
171  """Manage authentication tokens for Chromoting/xmpp"""
172
173  def __init__(self):
174    self.login = None
175    self.oauth_refresh_token = None
176
177  def copy_from(self, config):
178    """Loads the config and returns false if the config is invalid."""
179    try:
180      self.login = config["xmpp_login"]
181      self.oauth_refresh_token = config["oauth_refresh_token"]
182    except KeyError:
183      return False
184    return True
185
186  def copy_to(self, config):
187    config["xmpp_login"] = self.login
188    config["oauth_refresh_token"] = self.oauth_refresh_token
189
190
191class Host:
192  """This manages the configuration for a host."""
193
194  def __init__(self):
195    self.host_id = str(uuid.uuid1())
196    self.host_name = socket.gethostname()
197    self.host_secret_hash = None
198    self.private_key = None
199
200  def copy_from(self, config):
201    try:
202      self.host_id = config["host_id"]
203      self.host_name = config["host_name"]
204      self.host_secret_hash = config.get("host_secret_hash")
205      self.private_key = config["private_key"]
206    except KeyError:
207      return False
208    return True
209
210  def copy_to(self, config):
211    config["host_id"] = self.host_id
212    config["host_name"] = self.host_name
213    config["host_secret_hash"] = self.host_secret_hash
214    config["private_key"] = self.private_key
215
216
217class Desktop:
218  """Manage a single virtual desktop"""
219
220  def __init__(self, sizes):
221    self.x_proc = None
222    self.session_proc = None
223    self.host_proc = None
224    self.child_env = None
225    self.sizes = sizes
226    self.pulseaudio_pipe = None
227    self.server_supports_exact_resize = False
228    self.host_ready = False
229    self.ssh_auth_sockname = None
230    g_desktops.append(self)
231
232  @staticmethod
233  def get_unused_display_number():
234    """Return a candidate display number for which there is currently no
235    X Server lock file"""
236    display = FIRST_X_DISPLAY_NUMBER
237    while os.path.exists(X_LOCK_FILE_TEMPLATE % display):
238      display += 1
239    return display
240
241  def _init_child_env(self):
242    # Create clean environment for new session, so it is cleanly separated from
243    # the user's console X session.
244    self.child_env = {}
245
246    for key in [
247        "HOME",
248        "LANG",
249        "LOGNAME",
250        "PATH",
251        "SHELL",
252        "USER",
253        "USERNAME",
254        LOG_FILE_ENV_VAR]:
255      if os.environ.has_key(key):
256        self.child_env[key] = os.environ[key]
257
258    # Ensure that the software-rendering GL drivers are loaded by the desktop
259    # session, instead of any hardware GL drivers installed on the system.
260    self.child_env["LD_LIBRARY_PATH"] = (
261        "/usr/lib/%(arch)s-linux-gnu/mesa:"
262        "/usr/lib/%(arch)s-linux-gnu/dri:"
263        "/usr/lib/%(arch)s-linux-gnu/gallium-pipe" %
264        { "arch": platform.machine() })
265
266    # Read from /etc/environment if it exists, as it is a standard place to
267    # store system-wide environment settings. During a normal login, this would
268    # typically be done by the pam_env PAM module, depending on the local PAM
269    # configuration.
270    env_filename = "/etc/environment"
271    try:
272      with open(env_filename, "r") as env_file:
273        for line in env_file:
274          line = line.rstrip("\n")
275          # Split at the first "=", leaving any further instances in the value.
276          key_value_pair = line.split("=", 1)
277          if len(key_value_pair) == 2:
278            key, value = tuple(key_value_pair)
279            # The file stores key=value assignments, but the value may be
280            # quoted, so strip leading & trailing quotes from it.
281            value = value.strip("'\"")
282            self.child_env[key] = value
283    except IOError:
284      logging.info("Failed to read %s, skipping." % env_filename)
285
286  def _setup_pulseaudio(self):
287    self.pulseaudio_pipe = None
288
289    # pulseaudio uses UNIX sockets for communication. Length of UNIX socket
290    # name is limited to 108 characters, so audio will not work properly if
291    # the path is too long. To workaround this problem we use only first 10
292    # symbols of the host hash.
293    pulse_path = os.path.join(CONFIG_DIR,
294                              "pulseaudio#%s" % g_host_hash[0:10])
295    if len(pulse_path) + len("/native") >= 108:
296      logging.error("Audio will not be enabled because pulseaudio UNIX " +
297                    "socket path is too long.")
298      return False
299
300    sink_name = "chrome_remote_desktop_session"
301    pipe_name = os.path.join(pulse_path, "fifo_output")
302
303    try:
304      if not os.path.exists(pulse_path):
305        os.mkdir(pulse_path)
306    except IOError, e:
307      logging.error("Failed to create pulseaudio pipe: " + str(e))
308      return False
309
310    try:
311      pulse_config = open(os.path.join(pulse_path, "daemon.conf"), "w")
312      pulse_config.write("default-sample-format = s16le\n")
313      pulse_config.write("default-sample-rate = 48000\n")
314      pulse_config.write("default-sample-channels = 2\n")
315      pulse_config.close()
316
317      pulse_script = open(os.path.join(pulse_path, "default.pa"), "w")
318      pulse_script.write("load-module module-native-protocol-unix\n")
319      pulse_script.write(
320          ("load-module module-pipe-sink sink_name=%s file=\"%s\" " +
321           "rate=48000 channels=2 format=s16le\n") %
322          (sink_name, pipe_name))
323      pulse_script.close()
324    except IOError, e:
325      logging.error("Failed to write pulseaudio config: " + str(e))
326      return False
327
328    self.child_env["PULSE_CONFIG_PATH"] = pulse_path
329    self.child_env["PULSE_RUNTIME_PATH"] = pulse_path
330    self.child_env["PULSE_STATE_PATH"] = pulse_path
331    self.child_env["PULSE_SINK"] = sink_name
332    self.pulseaudio_pipe = pipe_name
333
334    return True
335
336  def _setup_gnubby(self):
337    self.ssh_auth_sockname = ("/tmp/chromoting.%s.ssh_auth_sock" %
338                              os.environ["USER"])
339
340  def _launch_x_server(self, extra_x_args):
341    x_auth_file = os.path.expanduser("~/.Xauthority")
342    self.child_env["XAUTHORITY"] = x_auth_file
343    devnull = open(os.devnull, "rw")
344    display = self.get_unused_display_number()
345
346    # Run "xauth add" with |child_env| so that it modifies the same XAUTHORITY
347    # file which will be used for the X session.
348    ret_code = subprocess.call("xauth add :%d . `mcookie`" % display,
349                               env=self.child_env, shell=True)
350    if ret_code != 0:
351      raise Exception("xauth failed with code %d" % ret_code)
352
353    max_width = max([width for width, height in self.sizes])
354    max_height = max([height for width, height in self.sizes])
355
356    xvfb = get_randr_supporting_x_server()
357    if xvfb:
358      self.server_supports_exact_resize = True
359    else:
360      xvfb = "Xvfb"
361      self.server_supports_exact_resize = False
362
363    # Disable the Composite extension iff the X session is the default
364    # Unity-2D, since it uses Metacity which fails to generate DAMAGE
365    # notifications correctly. See crbug.com/166468.
366    x_session = choose_x_session()
367    if (len(x_session) == 2 and
368        x_session[1] == "/usr/bin/gnome-session --session=ubuntu-2d"):
369      extra_x_args.extend(["-extension", "Composite"])
370
371    logging.info("Starting %s on display :%d" % (xvfb, display))
372    screen_option = "%dx%dx24" % (max_width, max_height)
373    self.x_proc = subprocess.Popen(
374        [xvfb, ":%d" % display,
375         "-auth", x_auth_file,
376         "-nolisten", "tcp",
377         "-noreset",
378         "-screen", "0", screen_option
379        ] + extra_x_args)
380    if not self.x_proc.pid:
381      raise Exception("Could not start Xvfb.")
382
383    self.child_env["DISPLAY"] = ":%d" % display
384    self.child_env["CHROME_REMOTE_DESKTOP_SESSION"] = "1"
385
386    # Use a separate profile for any instances of Chrome that are started in
387    # the virtual session. Chrome doesn't support sharing a profile between
388    # multiple DISPLAYs, but Chrome Sync allows for a reasonable compromise.
389    chrome_profile = os.path.join(CONFIG_DIR, "chrome-profile")
390    self.child_env["CHROME_USER_DATA_DIR"] = chrome_profile
391
392    # Set SSH_AUTH_SOCK to the file name to listen on.
393    if self.ssh_auth_sockname:
394      self.child_env["SSH_AUTH_SOCK"] = self.ssh_auth_sockname
395
396    # Wait for X to be active.
397    for _test in range(5):
398      proc = subprocess.Popen("xdpyinfo", env=self.child_env, stdout=devnull)
399      _pid, retcode = os.waitpid(proc.pid, 0)
400      if retcode == 0:
401        break
402      time.sleep(0.5)
403    if retcode != 0:
404      raise Exception("Could not connect to Xvfb.")
405    else:
406      logging.info("Xvfb is active.")
407
408    # The remoting host expects the server to use "evdev" keycodes, but Xvfb
409    # starts configured to use the "base" ruleset, resulting in XKB configuring
410    # for "xfree86" keycodes, and screwing up some keys. See crbug.com/119013.
411    # Reconfigure the X server to use "evdev" keymap rules.  The X server must
412    # be started with -noreset otherwise it'll reset as soon as the command
413    # completes, since there are no other X clients running yet.
414    proc = subprocess.Popen("setxkbmap -rules evdev", env=self.child_env,
415                            shell=True)
416    _pid, retcode = os.waitpid(proc.pid, 0)
417    if retcode != 0:
418      logging.error("Failed to set XKB to 'evdev'")
419
420    # Register the screen sizes if the X server's RANDR extension supports it.
421    # Errors here are non-fatal; the X server will continue to run with the
422    # dimensions from the "-screen" option.
423    for width, height in self.sizes:
424      label = "%dx%d" % (width, height)
425      args = ["xrandr", "--newmode", label, "0", str(width), "0", "0", "0",
426              str(height), "0", "0", "0"]
427      subprocess.call(args, env=self.child_env, stdout=devnull, stderr=devnull)
428      args = ["xrandr", "--addmode", "screen", label]
429      subprocess.call(args, env=self.child_env, stdout=devnull, stderr=devnull)
430
431    # Set the initial mode to the first size specified, otherwise the X server
432    # would default to (max_width, max_height), which might not even be in the
433    # list.
434    label = "%dx%d" % self.sizes[0]
435    args = ["xrandr", "-s", label]
436    subprocess.call(args, env=self.child_env, stdout=devnull, stderr=devnull)
437
438    # Set the physical size of the display so that the initial mode is running
439    # at approximately 96 DPI, since some desktops require the DPI to be set to
440    # something realistic.
441    args = ["xrandr", "--dpi", "96"]
442    subprocess.call(args, env=self.child_env, stdout=devnull, stderr=devnull)
443
444    devnull.close()
445
446  def _launch_x_session(self):
447    # Start desktop session.
448    # The /dev/null input redirection is necessary to prevent the X session
449    # reading from stdin.  If this code runs as a shell background job in a
450    # terminal, any reading from stdin causes the job to be suspended.
451    # Daemonization would solve this problem by separating the process from the
452    # controlling terminal.
453    xsession_command = choose_x_session()
454    if xsession_command is None:
455      raise Exception("Unable to choose suitable X session command.")
456
457    logging.info("Launching X session: %s" % xsession_command)
458    self.session_proc = subprocess.Popen(xsession_command,
459                                         stdin=open(os.devnull, "r"),
460                                         cwd=HOME_DIR,
461                                         env=self.child_env)
462    if not self.session_proc.pid:
463      raise Exception("Could not start X session")
464
465  def launch_session(self, x_args):
466    self._init_child_env()
467    self._setup_pulseaudio()
468    self._setup_gnubby()
469    self._launch_x_server(x_args)
470    self._launch_x_session()
471
472  def launch_host(self, host_config):
473    # Start remoting host
474    args = [locate_executable(HOST_BINARY_NAME), "--host-config=-"]
475    if self.pulseaudio_pipe:
476      args.append("--audio-pipe-name=%s" % self.pulseaudio_pipe)
477    if self.server_supports_exact_resize:
478      args.append("--server-supports-exact-resize")
479    if self.ssh_auth_sockname:
480      args.append("--ssh-auth-sockname=%s" % self.ssh_auth_sockname)
481
482    # Have the host process use SIGUSR1 to signal a successful start.
483    def sigusr1_handler(signum, frame):
484      _ = signum, frame
485      logging.info("Host ready to receive connections.")
486      self.host_ready = True
487      if (ParentProcessLogger.instance() and
488          False not in [desktop.host_ready for desktop in g_desktops]):
489        ParentProcessLogger.instance().release_parent()
490
491    signal.signal(signal.SIGUSR1, sigusr1_handler)
492    args.append("--signal-parent")
493
494    self.host_proc = subprocess.Popen(args, env=self.child_env,
495                                      stdin=subprocess.PIPE)
496    logging.info(args)
497    if not self.host_proc.pid:
498      raise Exception("Could not start Chrome Remote Desktop host")
499    self.host_proc.stdin.write(json.dumps(host_config.data))
500    self.host_proc.stdin.close()
501
502
503def get_daemon_proc():
504  """Checks if there is already an instance of this script running, and returns
505  a psutil.Process instance for it.
506
507  Returns:
508    A Process instance for the existing daemon process, or None if the daemon
509    is not running.
510  """
511
512  uid = os.getuid()
513  this_pid = os.getpid()
514
515  # Support new & old psutil API. This is the right way to check, according to
516  # http://grodola.blogspot.com/2014/01/psutil-20-porting.html
517  if psutil.version_info >= (2, 0):
518    psget = lambda x: x()
519  else:
520    psget = lambda x: x
521
522  for process in psutil.process_iter():
523    # Skip any processes that raise an exception, as processes may terminate
524    # during iteration over the list.
525    try:
526      # Skip other users' processes.
527      if psget(process.uids).real != uid:
528        continue
529
530      # Skip the process for this instance.
531      if process.pid == this_pid:
532        continue
533
534      # |cmdline| will be [python-interpreter, script-file, other arguments...]
535      cmdline = psget(process.cmdline)
536      if len(cmdline) < 2:
537        continue
538      if cmdline[0] == sys.executable and cmdline[1] == sys.argv[0]:
539        return process
540    except (psutil.NoSuchProcess, psutil.AccessDenied):
541      continue
542
543  return None
544
545
546def choose_x_session():
547  """Chooses the most appropriate X session command for this system.
548
549  Returns:
550    A string containing the command to run, or a list of strings containing
551    the executable program and its arguments, which is suitable for passing as
552    the first parameter of subprocess.Popen().  If a suitable session cannot
553    be found, returns None.
554  """
555  XSESSION_FILES = [
556    SESSION_FILE_PATH,
557    SYSTEM_SESSION_FILE_PATH ]
558  for startup_file in XSESSION_FILES:
559    startup_file = os.path.expanduser(startup_file)
560    if os.path.exists(startup_file):
561      # Use the same logic that a Debian system typically uses with ~/.xsession
562      # (see /etc/X11/Xsession.d/50x11-common_determine-startup), to determine
563      # exactly how to run this file.
564      if os.access(startup_file, os.X_OK):
565        # "/bin/sh -c" is smart about how to execute the session script and
566        # works in cases where plain exec() fails (for example, if the file is
567        # marked executable, but is a plain script with no shebang line).
568        return ["/bin/sh", "-c", pipes.quote(startup_file)]
569      else:
570        shell = os.environ.get("SHELL", "sh")
571        return [shell, startup_file]
572
573  # Choose a session wrapper script to run the session. On some systems,
574  # /etc/X11/Xsession fails to load the user's .profile, so look for an
575  # alternative wrapper that is more likely to match the script that the
576  # system actually uses for console desktop sessions.
577  SESSION_WRAPPERS = [
578    "/usr/sbin/lightdm-session",
579    "/etc/gdm/Xsession",
580    "/etc/X11/Xsession" ]
581  for session_wrapper in SESSION_WRAPPERS:
582    if os.path.exists(session_wrapper):
583      if os.path.exists("/usr/bin/unity-2d-panel"):
584        # On Ubuntu 12.04, the default session relies on 3D-accelerated
585        # hardware. Trying to run this with a virtual X display produces
586        # weird results on some systems (for example, upside-down and
587        # corrupt displays).  So if the ubuntu-2d session is available,
588        # choose it explicitly.
589        return [session_wrapper, "/usr/bin/gnome-session --session=ubuntu-2d"]
590      else:
591        # Use the session wrapper by itself, and let the system choose a
592        # session.
593        return [session_wrapper]
594  return None
595
596
597def locate_executable(exe_name):
598  if IS_INSTALLED:
599    # If the script is running from its installed location, search the host
600    # binary only in the same directory.
601    paths_to_try = [ SCRIPT_PATH ]
602  else:
603    paths_to_try = map(lambda p: os.path.join(SCRIPT_PATH, p),
604                       [".", "../../../out/Debug", "../../../out/Release" ])
605  for path in paths_to_try:
606    exe_path = os.path.join(path, exe_name)
607    if os.path.exists(exe_path):
608      return exe_path
609
610  raise Exception("Could not locate executable '%s'" % exe_name)
611
612
613class ParentProcessLogger(object):
614  """Redirects logs to the parent process, until the host is ready or quits.
615
616  This class creates a pipe to allow logging from the daemon process to be
617  copied to the parent process. The daemon process adds a log-handler that
618  directs logging output to the pipe. The parent process reads from this pipe
619  until and writes the content to stderr.  When the pipe is no longer needed
620  (for example, the host signals successful launch or permanent failure), the
621  daemon removes the log-handler and closes the pipe, causing the the parent
622  process to reach end-of-file while reading the pipe and exit.
623
624  The (singleton) logger should be instantiated before forking. The parent
625  process should call wait_for_logs() before exiting. The (grand-)child process
626  should call start_logging() when it starts, and then use logging.* to issue
627  log statements, as usual. When the child has either succesfully started the
628  host or terminated, it must call release_parent() to allow the parent to exit.
629  """
630
631  __instance = None
632
633  def __init__(self):
634    """Constructor. Must be called before forking."""
635    read_pipe, write_pipe = os.pipe()
636    # Ensure write_pipe is closed on exec, otherwise it will be kept open by
637    # child processes (X, host), preventing the read pipe from EOF'ing.
638    old_flags = fcntl.fcntl(write_pipe, fcntl.F_GETFD)
639    fcntl.fcntl(write_pipe, fcntl.F_SETFD, old_flags | fcntl.FD_CLOEXEC)
640    self._read_file = os.fdopen(read_pipe, 'r')
641    self._write_file = os.fdopen(write_pipe, 'a')
642    self._logging_handler = None
643    ParentProcessLogger.__instance = self
644
645  def start_logging(self):
646    """Installs a logging handler that sends log entries to a pipe.
647
648    Must be called by the child process.
649    """
650    self._read_file.close()
651    self._logging_handler = logging.StreamHandler(self._write_file)
652    logging.getLogger().addHandler(self._logging_handler)
653
654  def release_parent(self):
655    """Uninstalls logging handler and closes the pipe, releasing the parent.
656
657    Must be called by the child process.
658    """
659    if self._logging_handler:
660      logging.getLogger().removeHandler(self._logging_handler)
661      self._logging_handler = None
662    if not self._write_file.closed:
663      self._write_file.close()
664
665  def wait_for_logs(self):
666    """Waits and prints log lines from the daemon until the pipe is closed.
667
668    Must be called by the parent process.
669    """
670    # If Ctrl-C is pressed, inform the user that the daemon is still running.
671    # This signal will cause the read loop below to stop with an EINTR IOError.
672    def sigint_handler(signum, frame):
673      _ = signum, frame
674      print >> sys.stderr, ("Interrupted. The daemon is still running in the "
675                            "background.")
676
677    signal.signal(signal.SIGINT, sigint_handler)
678
679    # Install a fallback timeout to release the parent process, in case the
680    # daemon never responds (e.g. host crash-looping, daemon killed).
681    # This signal will cause the read loop below to stop with an EINTR IOError.
682    def sigalrm_handler(signum, frame):
683      _ = signum, frame
684      print >> sys.stderr, ("No response from daemon. It may have crashed, or "
685                            "may still be running in the background.")
686
687    signal.signal(signal.SIGALRM, sigalrm_handler)
688    signal.alarm(30)
689
690    self._write_file.close()
691
692    # Print lines as they're logged to the pipe until EOF is reached or readline
693    # is interrupted by one of the signal handlers above.
694    try:
695      for line in iter(self._read_file.readline, ''):
696        sys.stderr.write(line)
697    except IOError as e:
698      if e.errno != errno.EINTR:
699        raise
700    print >> sys.stderr, "Log file: %s" % os.environ[LOG_FILE_ENV_VAR]
701
702  @staticmethod
703  def instance():
704    """Returns the singleton instance, if it exists."""
705    return ParentProcessLogger.__instance
706
707
708def daemonize():
709  """Background this process and detach from controlling terminal, redirecting
710  stdout/stderr to a log file."""
711
712  # TODO(lambroslambrou): Having stdout/stderr redirected to a log file is not
713  # ideal - it could create a filesystem DoS if the daemon or a child process
714  # were to write excessive amounts to stdout/stderr.  Ideally, stdout/stderr
715  # should be redirected to a pipe or socket, and a process at the other end
716  # should consume the data and write it to a logging facility which can do
717  # data-capping or log-rotation. The 'logger' command-line utility could be
718  # used for this, but it might cause too much syslog spam.
719
720  # Create new (temporary) file-descriptors before forking, so any errors get
721  # reported to the main process and set the correct exit-code.
722  # The mode is provided, since Python otherwise sets a default mode of 0777,
723  # which would result in the new file having permissions of 0777 & ~umask,
724  # possibly leaving the executable bits set.
725  if not os.environ.has_key(LOG_FILE_ENV_VAR):
726    log_file_prefix = "chrome_remote_desktop_%s_" % time.strftime(
727        '%Y%m%d_%H%M%S', time.localtime(time.time()))
728    log_file = tempfile.NamedTemporaryFile(prefix=log_file_prefix, delete=False)
729    os.environ[LOG_FILE_ENV_VAR] = log_file.name
730    log_fd = log_file.file.fileno()
731  else:
732    log_fd = os.open(os.environ[LOG_FILE_ENV_VAR],
733                     os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0600)
734
735  devnull_fd = os.open(os.devnull, os.O_RDONLY)
736
737  parent_logger = ParentProcessLogger()
738
739  pid = os.fork()
740
741  if pid == 0:
742    # Child process
743    os.setsid()
744
745    # The second fork ensures that the daemon isn't a session leader, so that
746    # it doesn't acquire a controlling terminal.
747    pid = os.fork()
748
749    if pid == 0:
750      # Grandchild process
751      pass
752    else:
753      # Child process
754      os._exit(0)  # pylint: disable=W0212
755  else:
756    # Parent process
757    parent_logger.wait_for_logs()
758    os._exit(0)  # pylint: disable=W0212
759
760  logging.info("Daemon process started in the background, logging to '%s'" %
761               os.environ[LOG_FILE_ENV_VAR])
762
763  os.chdir(HOME_DIR)
764
765  parent_logger.start_logging()
766
767  # Copy the file-descriptors to create new stdin, stdout and stderr.  Note
768  # that dup2(oldfd, newfd) closes newfd first, so this will close the current
769  # stdin, stdout and stderr, detaching from the terminal.
770  os.dup2(devnull_fd, sys.stdin.fileno())
771  os.dup2(log_fd, sys.stdout.fileno())
772  os.dup2(log_fd, sys.stderr.fileno())
773
774  # Close the temporary file-descriptors.
775  os.close(devnull_fd)
776  os.close(log_fd)
777
778
779def cleanup():
780  logging.info("Cleanup.")
781
782  global g_desktops
783  for desktop in g_desktops:
784    if desktop.x_proc:
785      logging.info("Terminating Xvfb")
786      desktop.x_proc.terminate()
787  g_desktops = []
788  if ParentProcessLogger.instance():
789    ParentProcessLogger.instance().release_parent()
790
791class SignalHandler:
792  """Reload the config file on SIGHUP. Since we pass the configuration to the
793  host processes via stdin, they can't reload it, so terminate them. They will
794  be relaunched automatically with the new config."""
795
796  def __init__(self, host_config):
797    self.host_config = host_config
798
799  def __call__(self, signum, _stackframe):
800    if signum == signal.SIGHUP:
801      logging.info("SIGHUP caught, restarting host.")
802      try:
803        self.host_config.load()
804      except (IOError, ValueError) as e:
805        logging.error("Failed to load config: " + str(e))
806      for desktop in g_desktops:
807        if desktop.host_proc:
808          desktop.host_proc.send_signal(signal.SIGTERM)
809    else:
810      # Exit cleanly so the atexit handler, cleanup(), gets called.
811      raise SystemExit
812
813
814class RelaunchInhibitor:
815  """Helper class for inhibiting launch of a child process before a timeout has
816  elapsed.
817
818  A managed process can be in one of these states:
819    running, not inhibited (running == True)
820    stopped and inhibited (running == False and is_inhibited() == True)
821    stopped but not inhibited (running == False and is_inhibited() == False)
822
823  Attributes:
824    label: Name of the tracked process. Only used for logging.
825    running: Whether the process is currently running.
826    earliest_relaunch_time: Time before which the process should not be
827      relaunched, or 0 if there is no limit.
828    failures: The number of times that the process ran for less than a
829      specified timeout, and had to be inhibited.  This count is reset to 0
830      whenever the process has run for longer than the timeout.
831  """
832
833  def __init__(self, label):
834    self.label = label
835    self.running = False
836    self.earliest_relaunch_time = 0
837    self.earliest_successful_termination = 0
838    self.failures = 0
839
840  def is_inhibited(self):
841    return (not self.running) and (time.time() < self.earliest_relaunch_time)
842
843  def record_started(self, minimum_lifetime, relaunch_delay):
844    """Record that the process was launched, and set the inhibit time to
845    |timeout| seconds in the future."""
846    self.earliest_relaunch_time = time.time() + relaunch_delay
847    self.earliest_successful_termination = time.time() + minimum_lifetime
848    self.running = True
849
850  def record_stopped(self):
851    """Record that the process was stopped, and adjust the failure count
852    depending on whether the process ran long enough."""
853    self.running = False
854    if time.time() < self.earliest_successful_termination:
855      self.failures += 1
856    else:
857      self.failures = 0
858    logging.info("Failure count for '%s' is now %d", self.label, self.failures)
859
860
861def relaunch_self():
862  cleanup()
863  os.execvp(sys.argv[0], sys.argv)
864
865
866def waitpid_with_timeout(pid, deadline):
867  """Wrapper around os.waitpid() which waits until either a child process dies
868  or the deadline elapses.
869
870  Args:
871    pid: Process ID to wait for, or -1 to wait for any child process.
872    deadline: Waiting stops when time.time() exceeds this value.
873
874  Returns:
875    (pid, status): Same as for os.waitpid(), except that |pid| is 0 if no child
876    changed state within the timeout.
877
878  Raises:
879    Same as for os.waitpid().
880  """
881  while time.time() < deadline:
882    pid, status = os.waitpid(pid, os.WNOHANG)
883    if pid != 0:
884      return (pid, status)
885    time.sleep(1)
886  return (0, 0)
887
888
889def waitpid_handle_exceptions(pid, deadline):
890  """Wrapper around os.waitpid()/waitpid_with_timeout(), which waits until
891  either a child process exits or the deadline elapses, and retries if certain
892  exceptions occur.
893
894  Args:
895    pid: Process ID to wait for, or -1 to wait for any child process.
896    deadline: If non-zero, waiting stops when time.time() exceeds this value.
897      If zero, waiting stops when a child process exits.
898
899  Returns:
900    (pid, status): Same as for waitpid_with_timeout(). |pid| is non-zero if and
901    only if a child exited during the wait.
902
903  Raises:
904    Same as for os.waitpid(), except:
905      OSError with errno==EINTR causes the wait to be retried (this can happen,
906      for example, if this parent process receives SIGHUP).
907      OSError with errno==ECHILD means there are no child processes, and so
908      this function sleeps until |deadline|. If |deadline| is zero, this is an
909      error and the OSError exception is raised in this case.
910  """
911  while True:
912    try:
913      if deadline == 0:
914        pid_result, status = os.waitpid(pid, 0)
915      else:
916        pid_result, status = waitpid_with_timeout(pid, deadline)
917      return (pid_result, status)
918    except OSError, e:
919      if e.errno == errno.EINTR:
920        continue
921      elif e.errno == errno.ECHILD:
922        now = time.time()
923        if deadline == 0:
924          # No time-limit and no child processes. This is treated as an error
925          # (see docstring).
926          raise
927        elif deadline > now:
928          time.sleep(deadline - now)
929        return (0, 0)
930      else:
931        # Anything else is an unexpected error.
932        raise
933
934
935def main():
936  EPILOG = """This script is not intended for use by end-users.  To configure
937Chrome Remote Desktop, please install the app from the Chrome
938Web Store: https://chrome.google.com/remotedesktop"""
939  parser = optparse.OptionParser(
940      usage="Usage: %prog [options] [ -- [ X server options ] ]",
941      epilog=EPILOG)
942  parser.add_option("-s", "--size", dest="size", action="append",
943                    help="Dimensions of virtual desktop. This can be specified "
944                    "multiple times to make multiple screen resolutions "
945                    "available (if the Xvfb server supports this).")
946  parser.add_option("-f", "--foreground", dest="foreground", default=False,
947                    action="store_true",
948                    help="Don't run as a background daemon.")
949  parser.add_option("", "--start", dest="start", default=False,
950                    action="store_true",
951                    help="Start the host.")
952  parser.add_option("-k", "--stop", dest="stop", default=False,
953                    action="store_true",
954                    help="Stop the daemon currently running.")
955  parser.add_option("", "--get-status", dest="get_status", default=False,
956                    action="store_true",
957                    help="Prints host status")
958  parser.add_option("", "--check-running", dest="check_running", default=False,
959                    action="store_true",
960                    help="Return 0 if the daemon is running, or 1 otherwise.")
961  parser.add_option("", "--config", dest="config", action="store",
962                    help="Use the specified configuration file.")
963  parser.add_option("", "--reload", dest="reload", default=False,
964                    action="store_true",
965                    help="Signal currently running host to reload the config.")
966  parser.add_option("", "--add-user", dest="add_user", default=False,
967                    action="store_true",
968                    help="Add current user to the chrome-remote-desktop group.")
969  parser.add_option("", "--host-version", dest="host_version", default=False,
970                    action="store_true",
971                    help="Prints version of the host.")
972  (options, args) = parser.parse_args()
973
974  # Determine the filename of the host configuration and PID files.
975  if not options.config:
976    options.config = os.path.join(CONFIG_DIR, "host#%s.json" % g_host_hash)
977
978  # Check for a modal command-line option (start, stop, etc.)
979
980  if options.get_status:
981    proc = get_daemon_proc()
982    if proc is not None:
983      print "STARTED"
984    elif is_supported_platform():
985      print "STOPPED"
986    else:
987      print "NOT_IMPLEMENTED"
988    return 0
989
990  # TODO(sergeyu): Remove --check-running once NPAPI plugin and NM host are
991  # updated to always use get-status flag instead.
992  if options.check_running:
993    proc = get_daemon_proc()
994    return 1 if proc is None else 0
995
996  if options.stop:
997    proc = get_daemon_proc()
998    if proc is None:
999      print "The daemon is not currently running"
1000    else:
1001      print "Killing process %s" % proc.pid
1002      proc.terminate()
1003      try:
1004        proc.wait(timeout=30)
1005      except psutil.TimeoutExpired:
1006        print "Timed out trying to kill daemon process"
1007        return 1
1008    return 0
1009
1010  if options.reload:
1011    proc = get_daemon_proc()
1012    if proc is None:
1013      return 1
1014    proc.send_signal(signal.SIGHUP)
1015    return 0
1016
1017  if options.add_user:
1018    user = getpass.getuser()
1019    try:
1020      if user in grp.getgrnam(CHROME_REMOTING_GROUP_NAME).gr_mem:
1021        logging.info("User '%s' is already a member of '%s'." %
1022                     (user, CHROME_REMOTING_GROUP_NAME))
1023        return 0
1024    except KeyError:
1025      logging.info("Group '%s' not found." % CHROME_REMOTING_GROUP_NAME)
1026
1027    if os.getenv("DISPLAY"):
1028      sudo_command = "gksudo --description \"Chrome Remote Desktop\""
1029    else:
1030      sudo_command = "sudo"
1031    command = ("sudo -k && exec %(sudo)s -- sh -c "
1032               "\"groupadd -f %(group)s && gpasswd --add %(user)s %(group)s\"" %
1033               { 'group': CHROME_REMOTING_GROUP_NAME,
1034                 'user': user,
1035                 'sudo': sudo_command })
1036    os.execv("/bin/sh", ["/bin/sh", "-c", command])
1037    return 1
1038
1039  if options.host_version:
1040    # TODO(sergeyu): Also check RPM package version once we add RPM package.
1041    return os.system(locate_executable(HOST_BINARY_NAME) + " --version") >> 8
1042
1043  if not options.start:
1044    # If no modal command-line options specified, print an error and exit.
1045    print >> sys.stderr, EPILOG
1046    return 1
1047
1048  # If a RANDR-supporting Xvfb is not available, limit the default size to
1049  # something more sensible.
1050  if get_randr_supporting_x_server():
1051    default_sizes = DEFAULT_SIZES
1052  else:
1053    default_sizes = DEFAULT_SIZE_NO_RANDR
1054
1055  # Collate the list of sizes that XRANDR should support.
1056  if not options.size:
1057    if os.environ.has_key(DEFAULT_SIZES_ENV_VAR):
1058      default_sizes = os.environ[DEFAULT_SIZES_ENV_VAR]
1059    options.size = default_sizes.split(",")
1060
1061  sizes = []
1062  for size in options.size:
1063    size_components = size.split("x")
1064    if len(size_components) != 2:
1065      parser.error("Incorrect size format '%s', should be WIDTHxHEIGHT" % size)
1066
1067    try:
1068      width = int(size_components[0])
1069      height = int(size_components[1])
1070
1071      # Enforce minimum desktop size, as a sanity-check.  The limit of 100 will
1072      # detect typos of 2 instead of 3 digits.
1073      if width < 100 or height < 100:
1074        raise ValueError
1075    except ValueError:
1076      parser.error("Width and height should be 100 pixels or greater")
1077
1078    sizes.append((width, height))
1079
1080  # Register an exit handler to clean up session process and the PID file.
1081  atexit.register(cleanup)
1082
1083  # Load the initial host configuration.
1084  host_config = Config(options.config)
1085  try:
1086    host_config.load()
1087  except (IOError, ValueError) as e:
1088    print >> sys.stderr, "Failed to load config: " + str(e)
1089    return 1
1090
1091  # Register handler to re-load the configuration in response to signals.
1092  for s in [signal.SIGHUP, signal.SIGINT, signal.SIGTERM]:
1093    signal.signal(s, SignalHandler(host_config))
1094
1095  # Verify that the initial host configuration has the necessary fields.
1096  auth = Authentication()
1097  auth_config_valid = auth.copy_from(host_config)
1098  host = Host()
1099  host_config_valid = host.copy_from(host_config)
1100  if not host_config_valid or not auth_config_valid:
1101    logging.error("Failed to load host configuration.")
1102    return 1
1103
1104  # Determine whether a desktop is already active for the specified host
1105  # host configuration.
1106  proc = get_daemon_proc()
1107  if proc is not None:
1108    # Debian policy requires that services should "start" cleanly and return 0
1109    # if they are already running.
1110    print "Service already running."
1111    return 0
1112
1113  # Detach a separate "daemon" process to run the session, unless specifically
1114  # requested to run in the foreground.
1115  if not options.foreground:
1116    daemonize()
1117
1118  logging.info("Using host_id: " + host.host_id)
1119
1120  desktop = Desktop(sizes)
1121
1122  # Keep track of the number of consecutive failures of any child process to
1123  # run for longer than a set period of time. The script will exit after a
1124  # threshold is exceeded.
1125  # There is no point in tracking the X session process separately, since it is
1126  # launched at (roughly) the same time as the X server, and the termination of
1127  # one of these triggers the termination of the other.
1128  x_server_inhibitor = RelaunchInhibitor("X server")
1129  host_inhibitor = RelaunchInhibitor("host")
1130  all_inhibitors = [x_server_inhibitor, host_inhibitor]
1131
1132  # Don't allow relaunching the script on the first loop iteration.
1133  allow_relaunch_self = False
1134
1135  while True:
1136    # Set the backoff interval and exit if a process failed too many times.
1137    backoff_time = SHORT_BACKOFF_TIME
1138    for inhibitor in all_inhibitors:
1139      if inhibitor.failures >= MAX_LAUNCH_FAILURES:
1140        logging.error("Too many launch failures of '%s', exiting."
1141                      % inhibitor.label)
1142        return 1
1143      elif inhibitor.failures >= SHORT_BACKOFF_THRESHOLD:
1144        backoff_time = LONG_BACKOFF_TIME
1145
1146    relaunch_times = []
1147
1148    # If the session process or X server stops running (e.g. because the user
1149    # logged out), kill the other. This will trigger the next conditional block
1150    # as soon as os.waitpid() reaps its exit-code.
1151    if desktop.session_proc is None and desktop.x_proc is not None:
1152      logging.info("Terminating X server")
1153      desktop.x_proc.terminate()
1154    elif desktop.x_proc is None and desktop.session_proc is not None:
1155      logging.info("Terminating X session")
1156      desktop.session_proc.terminate()
1157    elif desktop.x_proc is None and desktop.session_proc is None:
1158      # Both processes have terminated.
1159      if (allow_relaunch_self and x_server_inhibitor.failures == 0 and
1160          host_inhibitor.failures == 0):
1161        # Since the user's desktop is already gone at this point, there's no
1162        # state to lose and now is a good time to pick up any updates to this
1163        # script that might have been installed.
1164        logging.info("Relaunching self")
1165        relaunch_self()
1166      else:
1167        # If there is a non-zero |failures| count, restarting the whole script
1168        # would lose this information, so just launch the session as normal.
1169        if x_server_inhibitor.is_inhibited():
1170          logging.info("Waiting before launching X server")
1171          relaunch_times.append(x_server_inhibitor.earliest_relaunch_time)
1172        else:
1173          logging.info("Launching X server and X session.")
1174          desktop.launch_session(args)
1175          x_server_inhibitor.record_started(MINIMUM_PROCESS_LIFETIME,
1176                                            backoff_time)
1177          allow_relaunch_self = True
1178
1179    if desktop.host_proc is None:
1180      if host_inhibitor.is_inhibited():
1181        logging.info("Waiting before launching host process")
1182        relaunch_times.append(host_inhibitor.earliest_relaunch_time)
1183      else:
1184        logging.info("Launching host process")
1185        desktop.launch_host(host_config)
1186        host_inhibitor.record_started(MINIMUM_PROCESS_LIFETIME,
1187                                      backoff_time)
1188
1189    deadline = min(relaunch_times) if relaunch_times else 0
1190    pid, status = waitpid_handle_exceptions(-1, deadline)
1191    if pid == 0:
1192      continue
1193
1194    logging.info("wait() returned (%s,%s)" % (pid, status))
1195
1196    # When a process has terminated, and we've reaped its exit-code, any Popen
1197    # instance for that process is no longer valid. Reset any affected instance
1198    # to None.
1199    if desktop.x_proc is not None and pid == desktop.x_proc.pid:
1200      logging.info("X server process terminated")
1201      desktop.x_proc = None
1202      x_server_inhibitor.record_stopped()
1203
1204    if desktop.session_proc is not None and pid == desktop.session_proc.pid:
1205      logging.info("Session process terminated")
1206      desktop.session_proc = None
1207
1208    if desktop.host_proc is not None and pid == desktop.host_proc.pid:
1209      logging.info("Host process terminated")
1210      desktop.host_proc = None
1211      desktop.host_ready = False
1212      host_inhibitor.record_stopped()
1213
1214      # These exit-codes must match the ones used by the host.
1215      # See remoting/host/host_error_codes.h.
1216      # Delete the host or auth configuration depending on the returned error
1217      # code, so the next time this script is run, a new configuration
1218      # will be created and registered.
1219      if os.WIFEXITED(status):
1220        if os.WEXITSTATUS(status) == 100:
1221          logging.info("Host configuration is invalid - exiting.")
1222          return 0
1223        elif os.WEXITSTATUS(status) == 101:
1224          logging.info("Host ID has been deleted - exiting.")
1225          host_config.clear()
1226          host_config.save_and_log_errors()
1227          return 0
1228        elif os.WEXITSTATUS(status) == 102:
1229          logging.info("OAuth credentials are invalid - exiting.")
1230          return 0
1231        elif os.WEXITSTATUS(status) == 103:
1232          logging.info("Host domain is blocked by policy - exiting.")
1233          return 0
1234        # Nothing to do for Mac-only status 104 (login screen unsupported)
1235        elif os.WEXITSTATUS(status) == 105:
1236          logging.info("Username is blocked by policy - exiting.")
1237          return 0
1238        else:
1239          logging.info("Host exited with status %s." % os.WEXITSTATUS(status))
1240      elif os.WIFSIGNALED(status):
1241        logging.info("Host terminated by signal %s." % os.WTERMSIG(status))
1242
1243
1244if __name__ == "__main__":
1245  logging.basicConfig(level=logging.DEBUG,
1246                      format="%(asctime)s:%(levelname)s:%(message)s")
1247  sys.exit(main())
1248