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