Updating trunk VERSION from 978.0 to 979.0
[chromium-blink-merge.git] / remoting / tools / me2me_virtual_host.py
blob4ec4501ea0cbf4d989bf620b81e096fa03af109f
1 #!/usr/bin/env python
2 # Copyright (c) 2011 The Chromium Authors. All rights reserved.
3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file.
6 # Virtual Me2Me implementation. This script runs and manages the processes
7 # required for a Virtual Me2Me desktop, which are: X server, X desktop
8 # session, and Host process.
9 # This script is intended to run continuously as a background daemon
10 # process, running under an ordinary (non-root) user account.
12 import atexit
13 import getpass
14 import hashlib
15 import json
16 import logging
17 import optparse
18 import os
19 import random
20 import signal
21 import socket
22 import subprocess
23 import sys
24 import time
25 import urllib2
26 import uuid
28 # Local modules
29 import gaia_auth
30 import keygen
32 REMOTING_COMMAND = "remoting_me2me_host"
34 # Command-line switch for passing the config path to remoting_me2me_host.
35 HOST_CONFIG_SWITCH_NAME = "host-config"
37 # Needs to be an absolute path, since the current working directory is changed
38 # when this process self-daemonizes.
39 SCRIPT_PATH = os.path.dirname(sys.argv[0])
40 if SCRIPT_PATH:
41 SCRIPT_PATH = os.path.abspath(SCRIPT_PATH)
42 else:
43 SCRIPT_PATH = os.getcwd()
45 # These are relative to SCRIPT_PATH.
46 EXE_PATHS_TO_TRY = [
47 ".",
48 "../../out/Debug",
49 "../../out/Release"
52 CONFIG_DIR = os.path.expanduser("~/.config/chrome-remote-desktop")
53 HOME_DIR = os.environ["HOME"]
55 X_LOCK_FILE_TEMPLATE = "/tmp/.X%d-lock"
56 FIRST_X_DISPLAY_NUMBER = 20
58 X_AUTH_FILE = os.path.expanduser("~/.Xauthority")
59 os.environ["XAUTHORITY"] = X_AUTH_FILE
62 # Globals needed by the atexit cleanup() handler.
63 g_desktops = []
64 g_pidfile = None
67 class Authentication:
68 """Manage authentication tokens for Chromoting/xmpp"""
70 def __init__(self, config_file):
71 self.config_file = config_file
73 def refresh_tokens(self):
74 print "Email:",
75 self.login = raw_input()
76 password = getpass.getpass("Password: ")
78 chromoting_auth = gaia_auth.GaiaAuthenticator('chromoting')
79 self.chromoting_auth_token = chromoting_auth.authenticate(self.login,
80 password)
82 xmpp_authenticator = gaia_auth.GaiaAuthenticator('chromiumsync')
83 self.xmpp_auth_token = xmpp_authenticator.authenticate(self.login,
84 password)
86 def load_config(self):
87 try:
88 settings_file = open(self.config_file, 'r')
89 data = json.load(settings_file)
90 settings_file.close()
91 self.login = data["xmpp_login"]
92 self.chromoting_auth_token = data["chromoting_auth_token"]
93 self.xmpp_auth_token = data["xmpp_auth_token"]
94 except:
95 return False
96 return True
98 def save_config(self):
99 data = {
100 "xmpp_login": self.login,
101 "chromoting_auth_token": self.chromoting_auth_token,
102 "xmpp_auth_token": self.xmpp_auth_token,
104 # File will contain private keys, so deny read/write access to others.
105 old_umask = os.umask(0066)
106 settings_file = open(self.config_file, 'w')
107 settings_file.write(json.dumps(data, indent=2))
108 settings_file.close()
109 os.umask(old_umask)
112 class Host:
113 """This manages the configuration for a host.
115 Callers should instantiate a Host object (passing in a filename where the
116 config will be kept), then should call either of the methods:
118 * create_config(auth): Create a new Host configuration and register it with
119 the Directory Service (the "auth" parameter is used to authenticate with the
120 Service).
121 * load_config(): Load a config from disk, with details of an existing Host
122 registration.
124 After calling create_config() (or making any config changes) the method
125 save_config() should be called to save the details to disk.
128 server = 'www.googleapis.com'
129 url = 'https://' + server + '/chromoting/v1/@me/hosts'
131 def __init__(self, config_file):
132 self.config_file = config_file
134 def create_config(self, auth):
135 self.host_id = str(uuid.uuid1())
136 logging.info("HostId: " + self.host_id)
137 self.host_name = socket.gethostname()
138 logging.info("HostName: " + self.host_name)
140 logging.info("Generating RSA key pair...")
141 (self.private_key, public_key) = keygen.generateRSAKeyPair()
142 logging.info("Done")
144 json_data = {
145 "data": {
146 "hostId": self.host_id,
147 "hostName": self.host_name,
148 "publicKey": public_key,
151 params = json.dumps(json_data)
152 headers = {
153 "Authorization": "GoogleLogin auth=" + auth.chromoting_auth_token,
154 "Content-Type": "application/json",
157 request = urllib2.Request(self.url, params, headers)
158 opener = urllib2.OpenerDirector()
159 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
161 logging.info("Registering host with directory service...")
162 try:
163 res = urllib2.urlopen(request)
164 data = res.read()
165 except urllib2.HTTPError, err:
166 logging.error("Directory returned error: " + str(err))
167 logging.error(err.read())
168 sys.exit(1)
169 logging.info("Done")
171 def load_config(self):
172 try:
173 settings_file = open(self.config_file, 'r')
174 data = json.load(settings_file)
175 settings_file.close()
176 self.host_id = data["host_id"]
177 self.host_name = data["host_name"]
178 self.private_key = data["private_key"]
179 except:
180 return False
181 return True
183 def save_config(self):
184 data = {
185 "host_id": self.host_id,
186 "host_name": self.host_name,
187 "private_key": self.private_key,
189 os.umask(0066) # Set permission mask for created file.
190 settings_file = open(self.config_file, 'w')
191 settings_file.write(json.dumps(data, indent=2))
192 settings_file.close()
195 class Desktop:
196 """Manage a single virtual desktop"""
198 def __init__(self, width, height):
199 self.x_proc = None
200 self.width = width
201 self.height = height
202 g_desktops.append(self)
204 @staticmethod
205 def get_unused_display_number():
206 """Return a candidate display number for which there is currently no
207 X Server lock file"""
208 display = FIRST_X_DISPLAY_NUMBER
209 while os.path.exists(X_LOCK_FILE_TEMPLATE % display):
210 display += 1
211 return display
213 def launch_x_server(self, extra_x_args):
214 display = self.get_unused_display_number()
215 ret_code = subprocess.call("xauth add :%d . `mcookie`" % display,
216 shell=True)
217 if ret_code != 0:
218 raise Exception("xauth failed with code %d" % ret_code)
220 logging.info("Starting Xvfb on display :%d" % display);
221 screen_option = "%dx%dx24" % (self.width, self.height)
222 self.x_proc = subprocess.Popen(["Xvfb", ":%d" % display,
223 "-auth", X_AUTH_FILE,
224 "-nolisten", "tcp",
225 "-screen", "0", screen_option
226 ] + extra_x_args)
227 if not self.x_proc.pid:
228 raise Exception("Could not start Xvfb.")
230 # Create clean environment for new session, so it is cleanly separated from
231 # the user's console X session.
232 self.child_env = {"DISPLAY": ":%d" % display}
233 for key in [
234 "HOME",
235 "LOGNAME",
236 "PATH",
237 "SHELL",
238 "USER",
239 "USERNAME"]:
240 if os.environ.has_key(key):
241 self.child_env[key] = os.environ[key]
243 # Wait for X to be active.
244 for test in range(5):
245 proc = subprocess.Popen("xdpyinfo > /dev/null", env=self.child_env,
246 shell=True)
247 pid, retcode = os.waitpid(proc.pid, 0)
248 if retcode == 0:
249 break
250 time.sleep(0.5)
251 if retcode != 0:
252 raise Exception("Could not connect to Xvfb.")
253 else:
254 logging.info("Xvfb is active.")
256 def launch_x_session(self):
257 # Start desktop session
258 # The /dev/null input redirection is necessary to prevent Xsession from
259 # reading from stdin. If this code runs as a shell background job in a
260 # terminal, any reading from stdin causes the job to be suspended.
261 # Daemonization would solve this problem by separating the process from the
262 # controlling terminal.
263 session_proc = subprocess.Popen("/etc/X11/Xsession",
264 stdin=open(os.devnull, "r"),
265 cwd=HOME_DIR,
266 env=self.child_env)
267 if not session_proc.pid:
268 raise Exception("Could not start X session")
270 def launch_host(self, host):
271 # Start remoting host
272 args = [locate_executable(REMOTING_COMMAND),
273 "--%s=%s" % (HOST_CONFIG_SWITCH_NAME, host.config_file)]
274 self.host_proc = subprocess.Popen(args, env=self.child_env)
275 if not self.host_proc.pid:
276 raise Exception("Could not start remoting host")
279 class PidFile:
280 """Class to allow creating and deleting a file which holds the PID of the
281 running process. This is used to detect if a process is already running, and
282 inform the user of the PID. On process termination, the PID file is
283 deleted.
285 Note that PID files are not truly atomic or reliable, see
286 http://mywiki.wooledge.org/ProcessManagement for more discussion on this.
288 So this class is just to prevent the user from accidentally running two
289 instances of this script, and to report which PID may be the other running
290 instance.
293 def __init__(self, filename):
294 """Create an object to manage a PID file. This does not create the PID
295 file itself."""
296 self.filename = filename
297 self.created = False
299 def check_and_create_file(self):
300 """Attempt to create the PID file, checking first for any currently-running
301 process.
303 Returns:
304 Tuple (created, pid):
305 |created| is True if the new file was created, False if there was an
306 existing process running.
307 |pid| holds the process ID of the running instance if |created| is False.
308 If the PID file exists but the PID couldn't be read from the file
309 (perhaps if the data hasn't been written yet), 0 is returned.
311 Raises:
312 IOError: Filesystem error occurred.
314 if os.path.exists(self.filename):
315 pid_file = open(self.filename, 'r')
316 file_contents = pid_file.read()
317 pid_file.close()
319 try:
320 pid = int(file_contents)
321 except ValueError:
322 return False, 0
324 # Test to see if there's a process currently running with that PID.
325 # If there is no process running, the existing PID file is definitely
326 # stale and it is safe to overwrite it. Otherwise, report the PID as
327 # possibly a running instance of this script.
328 if os.path.exists("/proc/%d" % pid):
329 return False, pid
331 # Create new (or overwrite existing) PID file.
332 pid_file = open(self.filename, 'w')
333 pid_file.close()
334 self.created = True
335 return True, 0
337 def write_pid(self):
338 """Write the current process's PID to the PID file.
340 This is done separately from check_and_create_file() as this needs to be
341 called after any daemonization, when the correct PID becomes known. But
342 check_and_create_file() has to happen before daemonization, so that if
343 another instance is already running, this fact can be reported to the
344 user's terminal session. This also avoids corrupting the log file of the
345 other process, since daemonize() would create a new log file.
347 pid_file = open(self.filename, 'w')
348 pid_file.write('%d\n' % os.getpid())
349 pid_file.close()
351 def delete_file(self):
352 """Delete the PID file if it was created by this instance.
354 This is called on process termination.
356 if self.created:
357 os.remove(self.filename)
360 def locate_executable(exe_name):
361 for path in EXE_PATHS_TO_TRY:
362 exe_path = os.path.join(SCRIPT_PATH, path, exe_name)
363 if os.path.exists(exe_path):
364 return exe_path
366 raise Exception("Could not locate executable '%s'" % exe_name)
369 def daemonize(log_filename):
370 """Background this process and detach from controlling terminal, redirecting
371 stdout/stderr to |log_filename|."""
373 # Create new (temporary) file-descriptors before forking, so any errors get
374 # reported to the main process and set the correct exit-code.
375 devnull_fd = os.open(os.devnull, os.O_RDONLY)
376 log_fd = os.open(log_filename, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
378 pid = os.fork()
380 if pid == 0:
381 # Child process
382 os.setsid()
384 # The second fork ensures that the daemon isn't a session leader, so that
385 # it doesn't acquire a controlling terminal.
386 pid = os.fork()
388 if pid == 0:
389 # Grandchild process
390 pass
391 else:
392 # Child process
393 os._exit(0)
394 else:
395 # Parent process
396 os._exit(0)
398 logging.info("Daemon process running, PID = %d, logging to '%s'" %
399 (os.getpid(), log_filename))
401 os.chdir(HOME_DIR)
403 # Copy the file-descriptors to create new stdin, stdout and stderr. Note
404 # that dup2(oldfd, newfd) closes newfd first, so this will close the current
405 # stdin, stdout and stderr, detaching from the terminal.
406 os.dup2(devnull_fd, sys.stdin.fileno())
407 os.dup2(log_fd, sys.stdout.fileno())
408 os.dup2(log_fd, sys.stderr.fileno())
410 # Close the temporary file-descriptors.
411 os.close(devnull_fd)
412 os.close(log_fd)
415 def cleanup():
416 logging.info("Cleanup.")
418 if g_pidfile:
419 try:
420 g_pidfile.delete_file()
421 except Exception, e:
422 logging.error("Unexpected error deleting PID file: " + str(e))
424 for desktop in g_desktops:
425 if desktop.x_proc:
426 logging.info("Terminating Xvfb")
427 desktop.x_proc.terminate()
430 def signal_handler(signum, stackframe):
431 # Exit cleanly so the atexit handler, cleanup(), gets called.
432 raise SystemExit
435 def main():
436 parser = optparse.OptionParser(
437 "Usage: %prog [options] [ -- [ X server options ] ]")
438 parser.add_option("-s", "--size", dest="size", default="1280x1024",
439 help="dimensions of virtual desktop (default: %default)")
440 parser.add_option("-f", "--foreground", dest="foreground", default=False,
441 action="store_true",
442 help="don't run as a background daemon")
443 (options, args) = parser.parse_args()
445 size_components = options.size.split("x")
446 if len(size_components) != 2:
447 parser.error("Incorrect size format, should be WIDTHxHEIGHT");
449 try:
450 width = int(size_components[0])
451 height = int(size_components[1])
453 # Enforce minimum desktop size, as a sanity-check. The limit of 100 will
454 # detect typos of 2 instead of 3 digits.
455 if width < 100 or height < 100:
456 raise ValueError
457 except ValueError:
458 parser.error("Width and height should be 100 pixels or greater")
460 atexit.register(cleanup)
462 for s in [signal.SIGHUP, signal.SIGINT, signal.SIGTERM]:
463 signal.signal(s, signal_handler)
465 # Ensure full path to config directory exists.
466 if not os.path.exists(CONFIG_DIR):
467 os.makedirs(CONFIG_DIR, mode=0700)
469 auth = Authentication(os.path.join(CONFIG_DIR, "auth.json"))
470 if not auth.load_config():
471 try:
472 auth.refresh_tokens()
473 except:
474 logging.error("Authentication failed.")
475 return 1
476 auth.save_config()
478 host_hash = hashlib.md5(socket.gethostname()).hexdigest()
479 host = Host(os.path.join(CONFIG_DIR, "host#%s.json" % host_hash))
481 if not host.load_config():
482 host.create_config(auth)
483 host.save_config()
485 pid_filename = os.path.join(CONFIG_DIR, "host#%s.pid" % host_hash)
486 global g_pidfile
487 g_pidfile = PidFile(pid_filename)
488 created, pid = g_pidfile.check_and_create_file()
490 if not created:
491 if pid == 0:
492 pid = 'unknown'
494 logging.error("An instance of this script is already running, PID is %s." %
495 pid)
496 logging.error("If this isn't the case, delete '%s' and try again." %
497 pid_filename)
498 return 1
500 # daemonize() must only be called after prompting for user/password, as the
501 # process will become detached from the controlling terminal.
502 log_filename = os.path.join(CONFIG_DIR, "host#%s.log" % host_hash)
504 if not options.foreground:
505 daemonize(log_filename)
507 g_pidfile.write_pid()
509 logging.info("Using host_id: " + host.host_id)
511 desktop = Desktop(width, height)
512 desktop.launch_x_server(args)
513 desktop.launch_x_session()
514 desktop.launch_host(host)
516 while True:
517 pid, status = os.wait()
518 logging.info("wait() returned (%s,%s)" % (pid, status))
520 if pid == desktop.x_proc.pid:
521 logging.info("X server process terminated with code %d", status)
522 break
524 if pid == desktop.host_proc.pid:
525 logging.info("Host process terminated, relaunching")
526 desktop.launch_host(host)
529 if __name__ == "__main__":
530 logging.basicConfig(level=logging.DEBUG)
531 sys.exit(main())