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