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