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.
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])
41 SCRIPT_PATH
= os
.path
.abspath(SCRIPT_PATH
)
43 SCRIPT_PATH
= os
.getcwd()
45 # These are relative to SCRIPT_PATH.
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.
68 """Manage authentication tokens for Chromoting/xmpp"""
70 def __init__(self
, config_file
):
71 self
.config_file
= config_file
73 def refresh_tokens(self
):
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
,
82 xmpp_authenticator
= gaia_auth
.GaiaAuthenticator('chromiumsync')
83 self
.xmpp_auth_token
= xmpp_authenticator
.authenticate(self
.login
,
86 def load_config(self
):
88 settings_file
= open(self
.config_file
, 'r')
89 data
= json
.load(settings_file
)
91 self
.login
= data
["xmpp_login"]
92 self
.chromoting_auth_token
= data
["chromoting_auth_token"]
93 self
.xmpp_auth_token
= data
["xmpp_auth_token"]
98 def save_config(self
):
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()
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
121 * load_config(): Load a config from disk, with details of an existing Host
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()
146 "hostId": self
.host_id
,
147 "hostName": self
.host_name
,
148 "publicKey": public_key
,
151 params
= json
.dumps(json_data
)
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...")
163 res
= urllib2
.urlopen(request
)
165 except urllib2
.HTTPError
, err
:
166 logging
.error("Directory returned error: " + str(err
))
167 logging
.error(err
.read())
171 def load_config(self
):
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"]
183 def save_config(self
):
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()
196 """Manage a single virtual desktop"""
198 def __init__(self
, width
, height
):
202 g_desktops
.append(self
)
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
):
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
,
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
,
225 "-screen", "0", screen_option
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
}
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
,
247 pid
, retcode
= os
.waitpid(proc
.pid
, 0)
252 raise Exception("Could not connect to Xvfb.")
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"),
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")
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
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
293 def __init__(self
, filename
):
294 """Create an object to manage a PID file. This does not create the PID
296 self
.filename
= filename
299 def check_and_create_file(self
):
300 """Attempt to create the PID file, checking first for any currently-running
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.
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()
320 pid
= int(file_contents
)
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
):
331 # Create new (or overwrite existing) PID file.
332 pid_file
= open(self
.filename
, 'w')
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())
351 def delete_file(self
):
352 """Delete the PID file if it was created by this instance.
354 This is called on process termination.
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
):
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
)
384 # The second fork ensures that the daemon isn't a session leader, so that
385 # it doesn't acquire a controlling terminal.
398 logging
.info("Daemon process running, PID = %d, logging to '%s'" %
399 (os
.getpid(), log_filename
))
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.
416 logging
.info("Cleanup.")
420 g_pidfile
.delete_file()
422 logging
.error("Unexpected error deleting PID file: " + str(e
))
424 for desktop
in g_desktops
:
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.
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,
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");
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:
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():
472 auth
.refresh_tokens()
474 logging
.error("Authentication failed.")
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
)
485 pid_filename
= os
.path
.join(CONFIG_DIR
, "host#%s.pid" % host_hash
)
487 g_pidfile
= PidFile(pid_filename
)
488 created
, pid
= g_pidfile
.check_and_create_file()
494 logging
.error("An instance of this script is already running, PID is %s." %
496 logging
.error("If this isn't the case, delete '%s' and try again." %
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
)
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
)
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
)