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