Ignore non-active fullscreen windows for shelf state.
[chromium-blink-merge.git] / remoting / tools / me2me_virtual_host.py
blobca228090727d7167ea873429e85c7fb1492b2333
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 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
32 LOG_FILE_ENV_VAR = "CHROME_REMOTE_DESKTOP_LOG_FILE"
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"
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"
44 SCRIPT_PATH = sys.path[0]
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"
52 CHROME_REMOTING_GROUP_NAME = "chrome-remote-desktop"
54 CONFIG_DIR = os.path.expanduser("~/.config/chrome-remote-desktop")
55 HOME_DIR = os.environ["HOME"]
57 X_LOCK_FILE_TEMPLATE = "/tmp/.X%d-lock"
58 FIRST_X_DISPLAY_NUMBER = 20
60 # Amount of time to wait between relaunching processes.
61 SHORT_BACKOFF_TIME = 5
62 LONG_BACKOFF_TIME = 60
64 # How long a process must run in order not to be counted against the restart
65 # thresholds.
66 MINIMUM_PROCESS_LIFETIME = 60
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
73 # Globals needed by the atexit cleanup() handler.
74 g_desktops = []
75 g_host_hash = hashlib.md5(socket.gethostname()).hexdigest()
77 class Config:
78 def __init__(self, path):
79 self.path = path
80 self.data = {}
81 self.changed = False
83 def load(self):
84 """Loads the config from file.
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()
95 def save(self):
96 """Saves the config to file.
98 Raises:
99 IOError: Error writing data
100 TypeError: Error serialising JSON
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)
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))
120 def get(self, key):
121 return self.data.get(key)
123 def __getitem__(self, key):
124 return self.data[key]
126 def __setitem__(self, key, value):
127 self.data[key] = value
128 self.changed = True
130 def clear(self):
131 self.data = {}
132 self.changed = True
135 class Authentication:
136 """Manage authentication tokens for Chromoting/xmpp"""
138 def __init__(self):
139 self.login = None
140 self.oauth_refresh_token = None
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
151 def copy_to(self, config):
152 config["xmpp_login"] = self.login
153 config["oauth_refresh_token"] = self.oauth_refresh_token
156 class Host:
157 """This manages the configuration for a host."""
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
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
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
182 class Desktop:
183 """Manage a single virtual desktop"""
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)
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
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 = {}
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]
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)
242 def _setup_pulseaudio(self):
243 self.pulseaudio_pipe = None
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
256 sink_name = "chrome_remote_desktop_session"
257 pipe_name = os.path.join(pulse_path, "fifo_output")
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
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()
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
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
292 return True
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()
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)
307 max_width = max([width for width, height in self.sizes])
308 max_height = max([height for width, height in self.sizes])
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
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"])
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.")
342 self.child_env["DISPLAY"] = ":%d" % display
343 self.child_env["CHROME_REMOTE_DESKTOP_SESSION"] = "1"
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
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.")
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'")
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)
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)
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)
399 devnull.close()
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.")
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")
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()
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")
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()
443 signal.signal(signal.SIGUSR1, sigusr1_handler)
444 args.append("--signal-parent")
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()
455 def get_daemon_pid():
456 """Checks if there is already an instance of this script running, and returns
457 its PID.
459 Returns:
460 The process ID of the existing daemon process, or 0 if the daemon is not
461 running.
463 uid = os.getuid()
464 this_pid = os.getpid()
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
474 # Skip the process for this instance.
475 if process.pid == this_pid:
476 continue
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
487 return 0
490 def choose_x_session():
491 """Chooses the most appropriate X session command for this system.
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.
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]
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
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
559 raise Exception("Could not locate executable '%s'" % exe_name)
562 class ParentProcessLogger(object):
563 """Redirects logs to the parent process, until the host is ready or quits.
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.
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.
580 __instance = None
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
594 def start_logging(self):
595 """Installs a logging handler that sends log entries to a pipe.
597 Must be called by the child process.
599 self._read_file.close()
600 self._logging_handler = logging.StreamHandler(self._write_file)
601 logging.getLogger().addHandler(self._logging_handler)
603 def release_parent(self):
604 """Uninstalls logging handler and closes the pipe, releasing the parent.
606 Must be called by the child process.
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()
614 def wait_for_logs(self):
615 """Waits and prints log lines from the daemon until the pipe is closed.
617 Must be called by the parent process.
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.")
626 signal.signal(signal.SIGINT, sigint_handler)
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.")
636 signal.signal(signal.SIGALRM, sigalrm_handler)
637 signal.alarm(30)
639 self._write_file.close()
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]
651 @staticmethod
652 def instance():
653 """Returns the singleton instance, if it exists."""
654 return ParentProcessLogger.__instance
657 def daemonize():
658 """Background this process and detach from controlling terminal, redirecting
659 stdout/stderr to a log file."""
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.
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)
684 devnull_fd = os.open(os.devnull, os.O_RDONLY)
686 parent_logger = ParentProcessLogger()
688 pid = os.fork()
690 if pid == 0:
691 # Child process
692 os.setsid()
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()
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
709 logging.info("Daemon process started in the background, logging to '%s'" %
710 os.environ[LOG_FILE_ENV_VAR])
712 os.chdir(HOME_DIR)
714 parent_logger.start_logging()
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())
723 # Close the temporary file-descriptors.
724 os.close(devnull_fd)
725 os.close(log_fd)
728 def cleanup():
729 logging.info("Cleanup.")
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()
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."""
745 def __init__(self, host_config):
746 self.host_config = host_config
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
763 class RelaunchInhibitor:
764 """Helper class for inhibiting launch of a child process before a timeout has
765 elapsed.
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)
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.
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
789 def is_inhibited(self):
790 return (not self.running) and (time.time() < self.earliest_relaunch_time)
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
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)
810 def relaunch_self():
811 cleanup()
812 os.execvp(sys.argv[0], sys.argv)
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.
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.
823 Returns:
824 (pid, status): Same as for os.waitpid(), except that |pid| is 0 if no child
825 changed state within the timeout.
827 Raises:
828 Same as for os.waitpid().
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)
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.
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.
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.
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.
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
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()
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)
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
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
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
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)
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
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
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
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(",")
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)
989 try:
990 width = int(size_components[0])
991 height = int(size_components[1])
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")
1000 sizes.append((width, height))
1002 # Register an exit handler to clean up session process and the PID file.
1003 atexit.register(cleanup)
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
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))
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
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
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()
1040 logging.info("Using host_id: " + host.host_id)
1042 desktop = Desktop(sizes)
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]
1054 # Don't allow relaunching the script on the first loop iteration.
1055 allow_relaunch_self = False
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
1068 relaunch_times = []
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
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)
1111 deadline = min(relaunch_times) if relaunch_times else 0
1112 pid, status = waitpid_handle_exceptions(-1, deadline)
1113 if pid == 0:
1114 continue
1116 logging.info("wait() returned (%s,%s)" % (pid, status))
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()
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
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()
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))
1166 if __name__ == "__main__":
1167 logging.basicConfig(level=logging.DEBUG,
1168 format="%(asctime)s:%(levelname)s:%(message)s")
1169 sys.exit(main())