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