#
#

# Copyright (C) 2007 Google Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.


"""nxserver program for accepting nx connections.

"""


import logging
import optparse
import socket
import subprocess
import sys

from neatx import cli
from neatx import constants
from neatx import errors
from neatx import node
from neatx import protocol
from neatx import session
from neatx import utils


PROGRAM = "nxserver"

NX_PROMPT_PARAMETERS = "Parameters: "

_SESSION_START_TIMEOUT = 30
_SESSION_RESTORE_TIMEOUT = 60

# TODO: Determine how the commercial NX version gets the depth from nxagent
DEFAULT_DEPTH = 24

LISTSESSION_COLUMNS = [
    ("Display", 7, lambda sess: sess.display),
    ("Type", 16, lambda sess: sess.type),
    ("Session ID", 32, lambda sess: sess.id),
    ("Options", 8, lambda sess: FormatOptions(sess)),
    ("Depth", -5, lambda sess: DEFAULT_DEPTH),
    ("Screen", 14, lambda sess: FormatGeometry(sess)),
    ("Status", 11, lambda sess: FormatStatus(sess)),
    ("Session Name", 30, lambda sess: sess.name),
    ]
"""
Column definitions for "listsession" command.

See L{utils.FormatTable} for more details.

"""


def FormatOptions(sess):
  """Format session options for "listsessions" command.

  """
  flags = []
  unset = "-"

  # Fullscreen
  if sess.fullscreen:
    flags.append("F")
  else:
    flags.append(unset)

  # Render
  if sess.screeninfo and "render" in sess.screeninfo:
    flags.append("R")
  else:
    flags.append(unset)

  # Non-rootless (Desktop?)
  if sess.virtualdesktop:
    flags.append("D")
  else:
    flags.append(unset)

  # Unknown
  flags.append(unset)
  flags.append(unset)
  flags.append("P")
  flags.append("S")
  flags.append("A")

  return "".join(flags)


def FormatGeometry(sess):
  if not sess.geometry:
    return "-"

  pos = sess.geometry.find("+")
  if pos == -1:
    return sess.geometry

  return sess.geometry[:pos]


def ConvertStatusForClient(status):
  """Convert status for client.

  The client doesn't know about the "terminating" and "suspending" statuses.

  @type status: str
  @param status: Server-side session status
  @rtype: str
  @return: Client-side session status

  """
  if status == constants.SESS_STATE_TERMINATING:
    return constants.SESS_STATE_TERMINATED

  if status == constants.SESS_STATE_SUSPENDING:
    return constants.SESS_STATE_SUSPENDED

  return status


def FormatStatus(sess):
  """Format session status for session list.

  """
  return ConvertStatusForClient(sess.state).capitalize()


def _GetSessionCache(sess):
  sesstype = sess.type
  if sesstype.startswith(constants.SESS_TYPE_UNIX_PREFIX):
    return sesstype
  return constants.SESS_TYPE_UNIX_PREFIX + sesstype


def GetClientSessionInfo(sess):
  """Get session information for the client

  This is used for starting/resuming a session.

  """
  # "702 Proxy IP: 1.2.3.4" is not used because we don't support unencrypted
  # sessions anyway.
  return [
    (700, "Session id: %s" % sess.full_id),
    (705, "Session display: %s" % sess.display),
    (703, "Session type: %s" % sess.type),
    (701, "Proxy cookie: %s" % sess.cookie),
    (706, "Agent cookie: %s" % sess.cookie),
    (704, "Session cache: %s" % _GetSessionCache(sess)),
    (728, "Session caption: %s" % sess.windowname),
    (707, "SSL tunneling: %s" % protocol.FormatNxBoolean(sess.ssl)),
    (708, "Subscription: %s" % sess.subscription),
    ]


class ServerCommandHandler(object):
  def __init__(self, server, ctx):
    self._server = server
    self._ctx = ctx

  def __call__(self, cmdline):
    """Parses and handles a command sent by the client.

    @type cmdline: str
    @param cmdline: Unparsed command

    """
    (cmd, args) = protocol.SplitCommand(cmdline)

    # Confirm command
    # TODO: Move confirmation code to protocol.py and use it from
    # nxserver_login.py, too.
    self._SendConfirmation(cmdline, cmd, args)

    if cmd in (protocol.NX_CMD_LOGIN,
               protocol.NX_CMD_HELLO,
               protocol.NX_CMD_SET):
      raise protocol.NxNotAfterLogin(cmd)

    try:
      if cmd == protocol.NX_CMD_BYE:
        return self._Bye()

      elif cmd == protocol.NX_CMD_LISTSESSION:
        return self._ListSession(args)

      elif cmd == protocol.NX_CMD_STARTSESSION:
        return self._StartSession(args)

      elif cmd == protocol.NX_CMD_ATTACHSESSION:
        return self._AttachSession(args)

      elif cmd == protocol.NX_CMD_RESTORESESSION:
        return self._RestoreSession(args)

    except errors.SessionParameterError, err:
      logging.exception("Session parameter error")
      raise protocol.NxProtocolError(500, err.args[0], fatal=True)

    raise protocol.NxUndefinedCommand(cmd)

  def _SendConfirmation(self, cmdline, cmd, args):
    """Sends a command confirmation to the client.

    """
    server = self._server

    if cmd == protocol.NX_CMD_STARTSESSION:
      self._server.WriteLine("Start session with: " + args)
      return

    # The "set" command uses a different confirmation in the commercial version
    # (as implemented in nxserver-login), but it shouldn't be used after login
    # anyway.

    server.WriteLine(cmdline.lstrip().capitalize())

  def _Bye(self):
    raise protocol.NxQuitServer()

  def _ListSession(self, args):
    """Handle the listsession NX command.

    "listsession" requests a table of session information for the current
    user. It requires parameters be specified.

    The following parameters have been seen:

      - C{--geometry="1920x1200x24+render"}:
        This seems to specify the desired geometry.
      - C{--status="suspended,running"}:
        This seems to specify the desired type.
      - C{--type="unix-gnome"}:
        This seems to constrain the list to sessions in the given states.
      - C{--user="someone"}:
        This seems to be ignored. No matter what is specified, the user given at
        login is used.

    @type args: string
    @param args: Parameters

    """
    ctx = self._ctx
    server = self._server
    mgr = ctx.session_mgr

    # Parse parameters
    parsed_params = dict(protocol.ParseParameters(self._GetParameters(args)))

    # TODO: Accepted parameters

    # Ignore --user, as per commercial implementation
    # TODO: Check sessions from all users if type=shadow? This is problematic
    # due to file system access permissions.
    find_users = [self._ctx.username]

    find_types = None
    want_shadow = False

    # Ignoring --user, as per commercial implementation

    if "type" in parsed_params:
      types = parsed_params["type"].split(",")

      # If the type is shadow do the settings to get running sessions
      if types[0] == constants.SESS_TYPE_SHADOW:
        want_shadow = True
      else:
        find_types = types

    if want_shadow:
      find_states = constants.SESS_STATE_RUNNING
    elif "status" in parsed_params:
      find_states = parsed_params["status"].split(",")
    else:
      find_states = None

    sessions = self._ListSessionInner(find_types, find_states)

    server.Write(127, "Session list of user '%s':" % ctx.username)
    for line in utils.FormatTable(sessions, LISTSESSION_COLUMNS):
      server.WriteLine(line)
    server.WriteLine("")
    server.Write(148, ("Server capacity: not reached for user: %s" %
                       ctx.username))

  def _ListSessionInner(self, find_types, find_states):
    """Returns a list of sessions filtered by parameters specified.

    @type find_types: list
    @param find_types: List of wanted session types
    @type find_states: list
    @param find_states: List of wanted (client) session states

    """
    ctx = self._ctx
    mgr = ctx.session_mgr

    logging.debug("Looking for sessions with types=%r, state=%r",
                  find_types, find_states)

    def _Filter(sess):
      if find_states and ConvertStatusForClient(sess.state) not in find_states:
        return False

      if find_types and sess.type not in find_types:
        return False

      return True

    return mgr.FindSessionsWithFilter(ctx.username, _Filter)

  def _StartSession(self, args):
    """Handle the startsession NX command.

    "startsession" seems to request a new session be started. It requires
    parameters be specified.

    The following parameters have been seen:

      - C{--backingstore="1"}
      - C{--cache="16M"}
      - C{--client="linux"}
      - C{--composite="1"}
      - C{--encryption="1"}
      - C{--fullscreen="0"}
      - C{--geometry="3840x1150"}
      - C{--images="64M"}
      - C{--keyboard="pc102/gb"}
      - C{--link="lan"}
      - C{--media="0"}
      - C{--rootless="0"}
      - C{--screeninfo="3840x1150x24+render"}
      - C{--session="localtest"}
      - C{--shmem="1"}
      - C{--shpix="1"}
      - C{--strict="0"}
      - C{--type="unix-gnome"}
      - C{--virtualdesktop="1"}

    Experiments with this command by directly invoking nxserver have not
    worked, as it refuses to create a session saying the unencrypted sessions
    are not supported. This is independent of whether the --encryption option
    has been set, so probably is related to the fact the nxserver has not been
    launched by sshd.

    @type args: string
    @param args: Parameters

    """
    ctx = self._ctx
    mgr = ctx.session_mgr
    server = self._server

    # Parse parameters
    params = self._GetParameters(args)
    parsed_params = dict(protocol.ParseParameters(params))

    # Parameters will be checked in nxnode

    sessid = mgr.CreateSessionID()
    logging.info("Starting new session %r", sessid)

    # Start nxnode daemon
    node.StartNodeDaemon(ctx.username, sessid)

    # Connect to daemon and tell it to start our session
    nodeclient = self._GetNodeClient(sessid, True)
    try:
      logging.debug("Sending startsession command")
      nodeclient.StartSession(parsed_params)
    finally:
      nodeclient.Close()

    # Wait for session
    self._ConnectToSession(sessid, _SESSION_START_TIMEOUT)

  def _AttachSession(self, args):
    """Handle the attachsession NX command.

    "attachsession" seems to request a new shadow session be started. It
    requires parameters be specified.

    The following parameters have been seen:
      - C{--backingstore="1"}
      - C{--cache="16M"}
      - C{--client="linux"}
      - C{--composite="1"}
      - C{--encryption="1"}
      - C{--geometry="3840x1150"}
      - C{--images="64M"}
      - C{--keyboard="pc102/gb"}
      - C{--link="lan"}
      - C{--media="0"}
      - C{--screeninfo="3840x1150x24+render"}
      - C{--session="localtest"}
      - C{--shmem="1"}
      - C{--shpix="1"}
      - C{--strict="0"}
      - C{--type="shadow"}

    @type args: string
    @param args: Parameters

    """
    ctx = self._ctx
    server = self._server
    mgr = self._ctx.session_mgr

    # Parse parameters
    params = self._GetParameters(args)
    parsed_params = dict(protocol.ParseParameters(params))

    # Parameters will be checked in nxnode

    try:
      shadowid = parsed_params["id"]
    except KeyError:
      raise protocol.NxProtocolError(500, ("Shadow session requested, "
                                           "but no session specified"))

    logging.info("Preparing to shadow session %r", shadowid)

    # Connect to daemon and ask for shadow cookie
    shadownodeclient = self._GetNodeClient(shadowid, False)
    try:
      logging.debug("Requesting shadow cookie from session %r", shadowid)
      shadowcookie = shadownodeclient.GetShadowCookie(None)
    finally:
      shadownodeclient.Close()

    logging.debug("Got shadow cookie %r", shadowcookie)

    sessid = mgr.CreateSessionID()
    logging.info("Starting new session %r", sessid)

    # Start nxnode daemon
    node.StartNodeDaemon(ctx.username, sessid)

    # Connect to daemon and tell it to shadow our session
    nodeclient = self._GetNodeClient(sessid, True)
    try:
      logging.debug("Sending attachsession command")
      nodeclient.AttachSession(parsed_params, shadowcookie)
    finally:
      nodeclient.Close()

    # Wait for session
    self._ConnectToSession(sessid, _SESSION_START_TIMEOUT)

  def _RestoreSession(self, args):
    """Handle the restoresession NX command.

    "restoresession" requests an existing session be resumed. It requires
    parameters be specified.

    The following parameters have been seen, from which at least the session id
    must be specified:

      - C{--backingstore="1"}
      - C{--cache="16M"}
      - C{--client="linux"}
      - C{--composite="1"}
      - C{--encryption="1"}
      - C{--geometry="3840x1150"}
      - C{--id="A28EBF5AAC354E9EEAFEEB867980C543"}
      - C{--images="64M"}
      - C{--keyboard="pc102/gb"}
      - C{--link="lan"}
      - C{--media="0"}
      - C{--rootless="1"}
      - C{--screeninfo="3840x1150x24+render"}
      - C{--session="localtest"}
      - C{--shmem="1"}
      - C{--shpix="1"}
      - C{--strict="0"}
      - C{--type="unix-gnome"}
      - C{--virtualdesktop="0"}

    @type args: string
    @param args: Parameters

    """
    ctx = self._ctx
    server = self._server
    mgr = ctx.session_mgr

    # Parse parameters
    params = self._GetParameters(args)
    parsed_params = dict(protocol.ParseParameters(params))

    # Parameters will be checked in nxnode

    try:
      sessid = parsed_params["id"]
    except KeyError:
      raise protocol.NxProtocolError(500, ("Restore session requested, "
                                           "but no session specified"))

    logging.info("Restoring session %r", sessid)

    # Try to find session
    sess = mgr.LoadSessionForUser(sessid, ctx.username)
    if sess is None:
      raise protocol.NxProtocolError(500, "Failed to load session")

    sessid = sess.id

    logging.info("Found session %r in session database", sessid)

    # Connect to daemon and tell it to restore our session
    nodeclient = self._GetNodeClient(sessid, False)
    try:
      logging.debug("Sending restoresession command")
      nodeclient.RestoreSession(parsed_params)
    finally:
      nodeclient.Close()

    # Already running sessions take a bit longer to restart
    self._ConnectToSession(sessid, _SESSION_RESTORE_TIMEOUT)

  def _GetParameters(self, args):
    """Returns parameters or, if none were given, query client for them.

    @type args: str
    @param args: Command arguments (can be empty)

    """
    server = self._server

    # Ask for parameters if none have been given
    if args:
      return args

    server.Write(106, NX_PROMPT_PARAMETERS, newline=False)
    try:
      return server.ReadLine()
    finally:
      server.WriteLine("")

  def _WriteSessionInfo(self, sess):
    """Writes session information required by client.

    @type sess: L{session.NxSession}
    @param sess: Session object

    """
    for code, message in GetClientSessionInfo(sess):
      self._server.Write(code, message=message)

  def _WaitForSessionReady(self, sessid, timeout):
    """Waits for a session to become ready for connecting.

    @type sessid: str
    @param sessid: Session ID
    @type timeout: int or float
    @param timeout: Timeout in seconds

    """
    mgr = self._ctx.session_mgr
    server = self._server

    def _CheckForSessionReady():
      sess = mgr.LoadSession(sessid)
      if sess:
        if sess.state == constants.SESS_STATE_WAITING:
          return sess

        elif sess.state in (constants.SESS_STATE_TERMINATING,
                            constants.SESS_STATE_TERMINATED):
          logging.error("Session %r has status %r", sess.id, sess.state)
          server.Write(500, message=("Error: Session %r has status %r, "
                                     "aborting") % (sess.id, sess.state))
          raise protocol.NxQuitServer()

      raise utils.RetryAgain()

    logging.info("Waiting for session %r to achieve waiting status",
                 sessid)

    try:
      return utils.Retry(_CheckForSessionReady, 0.1, 1.5, 1.0, timeout)
    except utils.RetryTimeout:
      logging.error(("Session %s has not achieved waiting status "
                     "within %s seconds"), sessid, timeout)
      server.Write(500, "Session didn't become ready in time")
      raise protocol.NxQuitServer()

  def _ConnectToSession(self, sessid, timeout):
    """Waits for a session to become ready and stores the port.

    @type sessid: str
    @param sessid: Session ID
    @type timeout: int or float
    @param timeout: Timeout in seconds

    """
    server = self._server

    # TODO: Instead of polling for the session, the daemon could only return
    # once the session is ready.

    # Wait for session to become ready
    sess = self._WaitForSessionReady(sessid, timeout)

    # Send session details to client
    self._WriteSessionInfo(sess)
    server.Write(710, "Session status: running")

    # Store session port for use by netcat
    self._ctx.nxagent_port = sess.port

  def _GetNodeClient(self, sessid, retry):
    """Starts the nxnode RPC client for a session.

    @type sessid: str
    @param sessid: Session ID
    @type retry: bool
    @param retry: Whether to retry connecting several times
    @rtype: L{node.NodeClient}
    @return: Node client object

    """
    ctx = self._ctx
    mgr = ctx.session_mgr

    # Connect to nxnode
    nodeclient = node.NodeClient(mgr.GetSessionNodeSocket(sessid))

    logging.debug("Connecting to nxnode")
    nodeclient.Connect(retry)

    return nodeclient


class NxServerContext(object):
  def __init__(self):
    self.username = None
    self.session_mgr = None
    self.nxagent_port = None


class NxServer(protocol.NxServerBase):
  def __init__(self, ctx):
    protocol.NxServerBase.__init__(self, sys.stdin, sys.stdout,
                                   ServerCommandHandler(self, ctx))
    self._ctx = ctx

  def SendBanner(self):
    """Send banner to peer.

    """
    # TODO: Hostname in configuration?
    hostname = socket.getfqdn().lower()
    username = self._ctx.username

    self.Write(103, message="Welcome to: %s user: %s" % (hostname, username))


class NxServerProgram(cli.GenericProgram):
  def BuildOptions(self):
    options = cli.GenericProgram.BuildOptions(self)
    options.extend([
      optparse.make_option("--proto", type="int", dest="proto"),
      ])
    return options

  def Run(self):
    if len(self.args) != 1:
      raise errors.GenericError("Username missing")

    (username, ) = self.args

    logging.info("Starting nxserver for user %s", username)

    ctx = NxServerContext()
    ctx.username = username
    ctx.session_mgr = session.NxSessionManager()

    try:
      NxServer(ctx).Start()
    finally:
      sys.stdout.flush()

    if ctx.nxagent_port is None:
      logging.debug("No nxagent port, not starting netcat")
    else:
      self._RunNetcat("localhost", ctx.nxagent_port)

  def _RunNetcat(self, host, port):
    """Starts netcat and returns only after it's done.

    @type host: str
    @param host: Hostname
    @type port: int
    @param port: Port

    """
    logging.info("Starting netcat (%s:%s)", host, port)

    stderr_logger = utils.LogFunctionWithPrefix(logging.error,
                                                "netcat stderr: ")

    args = [constants.NETCAT, "--", host, str(port)]

    process = subprocess.Popen(args, shell=False, close_fds=True,
                               stdin=None, stdout=None, stderr=subprocess.PIPE)

    for line in process.stderr:
      stderr_logger(line.rstrip())

    (exitcode, signum) = utils.GetExitcodeSignal(process.wait())
    if exitcode == 0 and signum is None:
      logging.debug("Netcat exited cleanly")
    else:
      logging.error("Netcat failed (code=%s, signal=%s)", exitcode, signum)


def Main():
  logsetup = utils.LoggingSetup(PROGRAM)
  NxServerProgram(logsetup).Main()
