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