/neatx/lib/agent.py
Python | 658 lines | 467 code | 73 blank | 118 comment | 31 complexity | b10ed428054b28d98e84215df445e941 MD5 | raw file
1# 2# 3 4# Copyright (C) 2009 Google Inc. 5# 6# This program is free software; you can redistribute it and/or modify 7# it under the terms of the GNU General Public License as published by 8# the Free Software Foundation; either version 2 of the License, or 9# (at your option) any later version. 10# 11# This program is distributed in the hope that it will be useful, but 12# WITHOUT ANY WARRANTY; without even the implied warranty of 13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 14# General Public License for more details. 15# 16# You should have received a copy of the GNU General Public License 17# along with this program; if not, write to the Free Software 18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 19# 02110-1301, USA. 20 21 22"""Module for nxagent interaction and session runtime 23 24 25""" 26 27 28import errno 29import gobject 30import logging 31import os 32import re 33import signal 34 35from cStringIO import StringIO 36 37from neatx import constants 38from neatx import daemon 39from neatx import errors 40from neatx import protocol 41from neatx import utils 42 43 44_STATUS_MAP = { 45 constants.SESS_STATE_STARTING: 46 re.compile(r"^Session:\s+Starting\s+session\s+at\s+"), 47 constants.SESS_STATE_WAITING: 48 re.compile(r"Info:\s+Waiting\s+for\s+connection\s+from\s+" 49 r"'(?P<host>.*)'\s+on\s+port\s+'(?P<port>\d+)'\."), 50 constants.SESS_STATE_RUNNING: 51 re.compile(r"^Session:\s+Session\s+(started|resumed)\s+at\s+"), 52 constants.SESS_STATE_SUSPENDING: 53 re.compile(r"^Session:\s+Suspending\s+session\s+at\s+"), 54 constants.SESS_STATE_SUSPENDED: 55 re.compile(r"^Session:\s+Session\s+suspended\s+at\s+"), 56 constants.SESS_STATE_TERMINATING: 57 re.compile(r"^Session:\s+(Terminat|Abort)ing\s+session\s+at\s+"), 58 constants.SESS_STATE_TERMINATED: 59 re.compile(r"^Session:\s+Session\s+(terminat|abort)ed\s+at\s+"), 60 } 61 62_WATCHDOG_PID_RE = re.compile(r"^Info:\s+Watchdog\s+running\s+with\s+pid\s+" 63 r"'(?P<pid>\d+)'\.") 64_WAIT_WATCHDOG_RE = re.compile(r"^Info:\s+Waiting\s+the\s+watchdog\s+" 65 r"process\s+to\s+complete\.") 66_AGENT_PID_RE = re.compile(r"^Info:\s+Agent\s+running\s+with\s+pid\s+" 67 r"'(?P<pid>\d+)'\.") 68_GENERAL_ERROR_RE = re.compile(r"^Error:\s+(?P<error>.*)$") 69_GENERAL_WARNING_RE = re.compile(r"^Warning:\s+(?P<warning>.*)$") 70_GEOMETRY_RE = re.compile(r"^Info:\s+Screen\s+\[0\]\s+resized\s+to\s+" 71 r"geometry\s+\[(?P<geometry>[^\]]+)\]" 72 r"( fullscreen \[(?P<fullscreen>\d)\])?\.$") 73 74 75class UserApplication(daemon.Program): 76 """Wraps the user-defined application. 77 78 """ 79 def __init__(self, env, cwd, args, logfile, login=False): 80 """Initializes this class. 81 82 @type env: dict 83 @param env: Environment variables 84 @type cwd: str 85 @param cwd: Working directory 86 @type args: list 87 @param args: Command and arguments 88 @type logfile: str 89 @param logfile: Path to application logfile 90 @type login: boolean 91 @param login: Run the command as a login shell 92 93 """ 94 if login: 95 executable = args[0] 96 args = args[:] 97 args[0] = "-%s" % os.path.basename(args[0]) 98 else: 99 executable = None 100 101 lang = self._GetLangEnv(env) 102 if lang: 103 env['LANG'] = lang 104 105 # TODO: logfile 106 107 daemon.Program.__init__(self, args, env=env, cwd=cwd, 108 executable=executable, 109 umask=constants.DEFAULT_APP_UMASK) 110 111 def _GetLangEnv(self, env): 112 lang_rx = re.compile("^\s*Language\s*=\s*(?P<lang>\S+)\s*$", re.M) 113 dmrc_path = os.path.expanduser("~/.dmrc") 114 115 if not os.path.exists(dmrc_path): 116 logging.debug("Dmrc doesn't exist") 117 return 118 logging.debug("Dmrc exists") 119 120 try: 121 contents = open(dmrc_path).read() 122 except IOError, err: 123 logging.warning("Error reading %r: %r", dmrc_path, err.strerror) 124 return 125 126 m = lang_rx.match(contents) 127 if not m: 128 logging.debug("Dmrc doesn't contain Language setting") 129 return 130 131 lang = m.group("lang") 132 logging.debug("Dmrc language setting %r", lang) 133 return lang 134 135 136class XAuthProgram(daemon.Program): 137 """Wrapper for xauth. 138 139 Quoting xauth(1): "The xauth program is used to edit and display the 140 authorization information used in connecting to the X server." 141 142 """ 143 _MIT_MAGIC_COOKIE_1 = "MIT-MAGIC-COOKIE-1" 144 145 def __init__(self, env, filename, cookies, cfg): 146 """Initializes this class. 147 148 @type env: dict 149 @param env: Environment variables 150 @type cookies: list of tuples 151 @param cookies: Cookies as [(display, cookie), ...] 152 @type cfg: L{config.Config} 153 @param cfg: Configuration object 154 155 """ 156 args = [cfg.xauth, "-f", filename] 157 daemon.Program.__init__(self, args, env=env, 158 stdin_data=self.__BuildInput(cookies)) 159 160 @classmethod 161 def __BuildInput(cls, cookies): 162 """Builds the input for xauth. 163 164 @type cookies: list of tuples 165 @param cookies: Cookies as [(display, cookie), ...] 166 167 """ 168 buf = StringIO() 169 170 for (display, cookie) in cookies: 171 buf.write("add %s %s %s\n" % (display, cls._MIT_MAGIC_COOKIE_1, cookie)) 172 173 buf.write("exit\n") 174 175 return buf.getvalue() 176 177 178class XRdbProgram(daemon.Program): 179 """Wrapper for xrdb. 180 181 Quoting xrdb(1): "X server resource database utility. Xrdb is used to get or 182 set the contents of the RESOURCE_MANAGER property [...] Most X clients use 183 the RESOURCE_MANAGER and SCREEN_RESOURCES properties to get user preferences 184 about color, fonts, and so on for applications." 185 186 """ 187 def __init__(self, env, settings, cfg): 188 args = [cfg.xrdb, "-merge"] 189 190 if not settings.endswith(os.linesep): 191 settings += os.linesep 192 193 xrdbenv = env.copy() 194 xrdbenv["LC_ALL"] = "C" 195 196 daemon.Program.__init__(self, args, env=xrdbenv, stdin_data=settings) 197 198 199class NxDialogProgram(daemon.Program): 200 """Wrapper for nxdialog program. 201 202 """ 203 def __init__(self, env, dlgtype, caption, message): 204 args = [constants.NXDIALOG, "--dialog", dlgtype, 205 "--caption", caption, "--message", message] 206 daemon.Program.__init__(self, args, env=env) 207 208 209class NxAgentProgram(daemon.Program): 210 """Wraps nxagent and acts on its output. 211 212 """ 213 DISPLAY_READY_SIGNAL = "display-ready" 214 215 __gsignals__ = { 216 DISPLAY_READY_SIGNAL: 217 (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, 218 ()), 219 } 220 221 def __init__(self, ctx): 222 """Initializes this class. 223 224 @type ctx: NxNodeContext 225 @param ctx: Nxnode context object 226 227 """ 228 self._ctx = ctx 229 230 args = [self._ctx.cfg.nxagent] + self._GetNxAgentArgs() 231 232 display = self._GetDisplayWithOptions() 233 logging.debug("Display for nxagent: %r", display) 234 235 env = ctx.session.GetSessionEnvVars().copy() 236 env["DISPLAY"] = display 237 env["NX_CLIENT"] = constants.NXDIALOG 238 239 self._agent_pid = None 240 self._watchdog_pid = None 241 self._want_restore = False 242 243 daemon.Program.__init__(self, args, env=env) 244 245 signal_name = daemon.ChopReader.SLICE_COMPLETE_SIGNAL 246 self._stderr_line_reg = \ 247 daemon.SignalRegistration(self, 248 self.stderr_line.connect(signal_name, 249 self._HandleStderrLine)) 250 251 def Start(self): 252 """Starts nxagent. 253 254 See L{daemon.Program.Start} for more details. 255 256 """ 257 # Ensure options file exists 258 self._UpdateOptionsFile() 259 260 pid = daemon.Program.Start(self) 261 self._agent_pid = pid 262 return pid 263 264 def Restore(self): 265 """Prepare session restore. 266 267 Depending on the current status, different things need to be done. If the 268 session is already suspended, SIGHUP can be sent right away. If it's still 269 running, sending SIGHUP will suspend the session and can be restored after 270 sending another SIGHUP. 271 272 """ 273 sess = self._ctx.session 274 275 if sess.state == constants.SESS_STATE_SUSPENDING: 276 # Restore once in suspended status 277 self._want_restore = True 278 279 elif sess.state == constants.SESS_STATE_SUSPENDED: 280 # Restore directly 281 self._PrepareSessionRestore() 282 283 elif sess.state == constants.SESS_STATE_RUNNING: 284 # Send SIGHUP to terminate session 285 self._SendSighup() 286 287 # Send SIGHUP again on status change 288 self._want_restore = True 289 290 else: 291 raise errors.GenericError("Cannot restore session in %r state" % 292 sess.state) 293 294 def Terminate(self): 295 """Terminates nxagent by sending SIGTERM. 296 297 """ 298 return self._SendSignal(signal.SIGTERM) 299 300 def _SendSighup(self): 301 """Sends a SIGHUP signal to nxagent. 302 303 """ 304 return self._SendSignal(signal.SIGHUP) 305 306 def _SendSignal(self, signum): 307 """Sends a signal to nxagent. 308 309 @type signum: int 310 @param signum: Signal number 311 312 """ 313 # Get signal name as string 314 signame = utils.GetSignalName(signum) 315 316 logging.info("Sending %s to nxagent", signame) 317 try: 318 os.kill(self._agent_pid, signum) 319 except OSError, err: 320 # kill(2) on ESRCH: The pid or process group does not exist. Note that 321 # an existing process might be a zombie, a process which already 322 # committed termination, but has not yet been wait(2)ed for. 323 if err.errno not in (errno.ESRCH, ): 324 raise 325 logging.exception("Failed to send %s to nxagent", signame) 326 327 def _HandleStderrLine(self, _, line): 328 """Handle a line on nxagent's stderr output. 329 330 @type line: string 331 @param line: Line without newline 332 333 """ 334 if self._CheckStatus(line): 335 return 336 337 m = _WATCHDOG_PID_RE.match(line) 338 if m: 339 self._watchdog_pid = int(m.group("pid")) 340 logging.info("Matched info watchdog, PID %r", self._watchdog_pid) 341 return 342 343 m = _AGENT_PID_RE.match(line) 344 if m: 345 real_agent_pid = int(m.group("pid")) 346 logging.info("Matched info agent_pid, PID %r", real_agent_pid) 347 348 if self._agent_pid != real_agent_pid: 349 # Probably caused by nxagent being a shell script 350 logging.warning("Agent pid (%r) doesn't match spawned PID (%r)", 351 self._agent_pid, real_agent_pid) 352 self._agent_pid = real_agent_pid 353 354 return 355 356 m = _WAIT_WATCHDOG_RE.match(line) 357 if m: 358 if self._watchdog_pid is None: 359 logging.error("Matched info kill_watchdog, but no known watchdog pid") 360 else: 361 # Before terminating, nxagent starts a separate process, called 362 # watchdog here, which must be sent SIGTERM. Otherwise it wouldn't 363 # terminate. 364 try: 365 os.kill(self._watchdog_pid, signal.SIGTERM) 366 except OSError, err: 367 logging.warning(("Matched info kill_watchdog, got error from " 368 "killing PID %r: %r"), self._watchdog_pid, err) 369 else: 370 logging.info("Matched info kill_watchdog, sent SIGTERM.") 371 372 return 373 374 m = _GENERAL_ERROR_RE.match(line) 375 if m: 376 logging.error("Agent error: %s", m.group("error")) 377 return 378 379 m = _GENERAL_WARNING_RE.match(line) 380 if m: 381 logging.warning("Agent warning: %s", m.group("warning")) 382 return 383 384 m = _GEOMETRY_RE.match(line) 385 if m: 386 geometry = m.group("geometry") 387 fullscreen = (m.group("fullscreen") == "1") 388 self._ChangeGeometry(geometry, fullscreen) 389 logging.info("Matched info geometry change, new is %r, fullscreen %r", 390 geometry, fullscreen) 391 return 392 393 def _CheckStatus(self, line, _status_map=None): 394 """Check whether the line indicates a session status change. 395 396 @type line: string 397 @param line: Line without newline 398 399 """ 400 sess = self._ctx.session 401 402 if _status_map is None: 403 _status_map = _STATUS_MAP 404 405 for status, rx in _status_map.iteritems(): 406 m = rx.match(line) 407 if m: 408 logging.info("Nxagent changed status from %r to %r", 409 sess.state, status) 410 self._ChangeStatus(m, sess.state, status) 411 return True 412 413 return False 414 415 def _ChangeStatus(self, m, old, new): 416 """Called when session status changed. 417 418 @type m: x 419 @param m: Regex match object 420 @type old: str 421 @param old: Previous session status 422 @type new: str 423 @param new: New session status 424 425 """ 426 sess = self._ctx.session 427 428 if new == old: 429 pass 430 431 elif new == constants.SESS_STATE_WAITING: 432 if old == constants.SESS_STATE_STARTING: 433 self.__EmitDisplayReady() 434 435 port = m.group("port") 436 437 try: 438 portnum = int(port) 439 except ValueError: 440 logging.warning("Port number for nxagent (%r) is not numeric", 441 port) 442 portnum = None 443 444 logging.debug("Setting session port to %r", portnum) 445 446 sess.port = portnum 447 448 elif (old == constants.SESS_STATE_SUSPENDING and 449 new == constants.SESS_STATE_SUSPENDED and 450 self._want_restore): 451 self._want_restore = False 452 453 self._PrepareSessionRestore() 454 455 elif (old == constants.SESS_STATE_TERMINATING and 456 new == constants.SESS_STATE_TERMINATED): 457 logging.info("Nxagent terminated") 458 459 sess.state = new 460 sess.Save() 461 462 def _PrepareSessionRestore(self): 463 """Prepare session restore by telling nxagent to reopen its port. 464 465 """ 466 # Write options file with new options 467 self._UpdateOptionsFile() 468 469 # Send SIGHUP to reopen port 470 self._SendSighup() 471 472 def _ChangeGeometry(self, geometry, fullscreen): 473 """Called when geometry changed. 474 475 @type geometry: str 476 @param geometry: Geometry information 477 @type fullscreen: boolean 478 @param fullscreen: Fullscreen state 479 480 """ 481 sess = self._ctx.session 482 sess.geometry = geometry 483 sess.fullscreen = fullscreen 484 sess.Save() 485 486 def _FormatNxAgentOptions(self, opts): 487 """Formats options for nxagent. 488 489 @type opts: dict 490 @param opts: Options 491 492 """ 493 sess = self._ctx.session 494 495 formatted = ",".join(["%s=%s" % (name, value) 496 for name, value in opts.iteritems()]) 497 498 return "nx/nx,%s:%d\n" % (formatted, sess.display) 499 500 def _GetDisplayWithOptions(self): 501 """Returns the value for the DISPLAY variable for nxagent. 502 503 """ 504 sess = self._ctx.session 505 506 self.__CheckStrChars(sess.optionsfile, "Session options file") 507 508 return "nx/nx,options=%s:%d" % (sess.optionsfile, sess.display) 509 510 def _GetOptions(self): 511 """Returns session options for nxagent. 512 513 @rtype: dict 514 @return: Options 515 516 """ 517 sess = self._ctx.session 518 519 # We need to write the type without the "unix-" prefix for nxagent 520 if sess.type.startswith(constants.SESS_TYPE_UNIX_PREFIX): 521 shorttype = sess.type[len(constants.SESS_TYPE_UNIX_PREFIX):] 522 else: 523 shorttype = sess.type 524 525 opts = { 526 # This limits what IPs nxagent will accept connections from. When using 527 # encrypted sessions, connections are always from localhost. Unencrypted 528 # connections come directly from nxclient. 529 # Note: Unencrypted connections are not supported. 530 "accept": "127.0.0.1", 531 "backingstore": "1", 532 "cache": protocol.FormatNxSize(sess.cache), 533 "cleanup": "0", 534 "client": sess.client, 535 "clipboard": "both", 536 "composite": "1", 537 "cookie": sess.cookie, 538 "id": sess.full_id, 539 "images": protocol.FormatNxSize(sess.images), 540 "keyboard": sess.keyboard, 541 "link": sess.link, 542 # TODO: What is this used for in nxagent? 543 "product": "Neatx-%s" % constants.DEFAULT_SUBSCRIPTION, 544 "render": "1", 545 "resize": protocol.FormatNxBoolean(sess.resize), 546 "shmem": "1", 547 "shpix": "1", 548 "strict": "0", 549 } 550 551 if sess.type == constants.SESS_TYPE_SHADOW: 552 # TODO: Make shadowmode configurable and/or controllable by the shadowed 553 # user. Be aware, though, that this flag is under the control of the 554 # shadowing user. 555 # 0 = view only, 1 = interactive 556 opts["shadowmode"] = "1" 557 # TODO: Check which UID we should pass here. 558 opts["shadowuid"] = self._ctx.uid 559 opts["shadow"] = ":%s" % sess.shadow_display 560 561 if sess.rootless: 562 opts["menu"] = "1" 563 else: 564 opts["geometry"] = sess.geometry 565 opts["fullscreen"] = protocol.FormatNxBoolean(sess.fullscreen) 566 567 if sess.rootless and sess.type == constants.SESS_TYPE_CONSOLE: 568 opts["type"] = "rootless" 569 else: 570 opts["type"] = shorttype 571 572 return opts 573 574 def _GetNxAgentArgs(self): 575 """Returns command line arguments for nxagent. 576 577 """ 578 sess = self._ctx.session 579 580 if sess.type == constants.SESS_TYPE_SHADOW: 581 # Run nxagent in shadow mode 582 mode = "-S" 583 584 elif sess.rootless: 585 # Run nxagent in rootless mode 586 mode = "-R" 587 588 else: 589 # Run nxagent in desktop mode 590 mode = "-D" 591 592 args = [ 593 mode, 594 "-name", sess.windowname, 595 "-options", self._ctx.session.optionsfile, 596 597 # Disable permanently-open TCP port for X protocol (doesn't affect 598 # nxagent port). 599 "-nolisten", "tcp", 600 601 ":%d" % sess.display, 602 ] 603 604 if sess.type == constants.SESS_TYPE_SHADOW: 605 args.append("-nopersistent") 606 607 return args 608 609 def _UpdateOptionsFile(self): 610 """Update session options file. 611 612 """ 613 self._WriteOptionsFile(self._GetOptions()) 614 615 def _WriteOptionsFile(self, opts): 616 """Writes session options to the session-specific options file. 617 618 @type opts: dict 619 @param opts: Options 620 621 """ 622 sess = self._ctx.session 623 filename = sess.optionsfile 624 self.__CheckOptsChars(opts) 625 formatted = self._FormatNxAgentOptions(opts) 626 627 logging.debug("Writing session options %r to %s", formatted, filename) 628 utils.WriteFile(filename, data=formatted, mode=0600) 629 630 def __EmitDisplayReady(self): 631 self.emit(self.DISPLAY_READY_SIGNAL) 632 633 def __CheckOptsChars(self, opts): 634 """Checks to make sure option name/values don't contain illegal characters. 635 636 @type opts: dict 637 @param opts: Options 638 639 """ 640 641 for name, value in opts.iteritems(): 642 self.__CheckStrChars(name, "Name of option %r" % name) 643 self.__CheckStrChars(value, "Value of option %r (%r)" % (name, value)) 644 645 def __CheckStrChars(self, s, description): 646 """Checks to make sure string don't contain illegal characters. 647 648 @type s: string 649 @param s: text to test 650 @type description: string 651 @param description: description of text 652 653 """ 654 illegal_chars = [","] 655 for c in illegal_chars: 656 if c in s: 657 raise errors.IllegalCharacterError("%s contains illegal character %r" % 658 (description, c))