/neatx/lib/agent.py

http://neatx.googlecode.com/ · Python · 658 lines · 417 code · 70 blank · 171 comment · 22 complexity · b10ed428054b28d98e84215df445e941 MD5 · raw file

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