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