- add sources.
[platform/framework/web/crosswalk.git] / src / remoting / tools / me2me_virtual_host.py
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())