Source code for slycat.web.server.remote

# Copyright (c) 2013, 2018 National Technology and Engineering Solutions of Sandia, LLC . Under the terms of Contract
# DE-NA0003525 with National Technology and Engineering Solutions of Sandia, LLC, the U.S. Government
# retains certain rights in this software.

"""Functions for managing cached remote ssh sessions.

Slycat makes extensive use of ssh and the `Slycat Agent` to access remote
resources located on the high performance computing platforms used to generate
ensembles.  This module provides functionality to create cached remote ssh /
agent sessions that can be used to retrieve data from remote hosts.  This
functionality is used in a variety of ways:

* Web clients can browse the filesystem of a remote host.
* Web clients can create a Slycat model using data stored on a remote host.
* Web clients can retrieve images on a remote host (an essential part of the parameter-image-model).
* Web clients can retrieve video compressed from still images on a remote host.

When a remote session is created, a connection to the remote host over ssh is
created, an agent is started (only if the required configuration is present),
and a unique session identifier is returned.  Callers use the session id to
retrieve the cached session and communicate with the remote host / agent.  A
"last access" time for each session is maintained and updated whenever the
cached session is accessed.  If a session times-out (a threshold amount of time
has elapsed since the last access) it is automatically deleted, and subsequent
use of the expired session id will fail.

Each session is bound to the IP address of the client that created it - only
the same client IP address is allowed to access the session.
"""

import datetime
import json
import os
import base64
import stat
import sys
import threading
import time
import uuid
import cherrypy
import paramiko
import socket

import slycat.mime_type
import slycat.web.server.authentication
import slycat.web.server.database
import slycat.web.server.streaming
import slycat.web.server

            
[docs] def cache_object(pid, key, content_type, content): cherrypy.log.error("cache_object %s %s %s" % (pid, key, content_type)) database = slycat.web.server.database.couchdb.connect() project = database.get("project", pid) slycat.web.server.authentication.require_project_reader(project) lookup = pid + "-" + key for cache_object in database.scan("slycat/project-key-cache-objects", startkey=lookup, endkey=lookup): database.put_attachment(cache_object, filename="content", content_type=content_type, content=content) return cache_object = { "_id": uuid.uuid4().hex, "type": "cache-object", "project": pid, "key": key, "created": datetime.datetime.utcnow().isoformat(), "creator": cherrypy.request.login, } database.save(cache_object) database.put_attachment(cache_object, filename="content", content_type=content_type, content=content)
session_cache = {} session_cache_lock = threading.Lock()
[docs] class Session(object): """Encapsulates an open session connected to a remote host. Examples -------- Calling threads must serialize access to the Session object. To facilitate this, a Session is a context manager - callers should always use a `with statement` when accessing a session: >>> with slycat.web.server.remote.get_session(sid) as session: ... print session.username """ def __init__(self, sid, client, username, hostname, ssh, sftp, agent=None): now = datetime.datetime.utcnow() self._client = client self._sid = sid self._username = username self._hostname = hostname self._ssh = ssh self._sftp = sftp self._agent = agent self._created = now self._accessed = now self._lock = threading.Lock() def __enter__(self): self._lock.__enter__() return self def __exit__(self, exc_type, exc_value, traceback): return self._lock.__exit__(exc_type, exc_value, traceback) @property def client(self): """Return the IP address of the client that created the session.""" return self._client @property def username(self): """Return the username used to create the session.""" return self._username @property def hostname(self): """Return the remote hostname accessed by the session.""" return self._hostname @property def sftp(self): return self._sftp @property def accessed(self): """Return the time the session was last accessed.""" return self._accessed
[docs] def close(self): if self._agent is not None: cherrypy.log.error( "Instructing remote agent for %s@%s from %s to shutdown." % (self.username, self.hostname, self.client)) stdin, stdout, stderr = self._agent command = {"action": "exit"} stdin.write("%s\n" % json.dumps(command)) stdin.flush() self._sftp.close() self._ssh.close()
[docs] def submit_batch(self, filename): """ Submits a command to the slycat-agent to start an input batch file on a cluster running SLURM. Parameters ---------- filename : string Name of the batch file Returns ------- response : dict A dictionary with the following keys: filename, jid, errors """ if self._agent is not None: stdin, stdout, stderr = self._agent payload = {"action": "submit-batch", "command": filename} stdin.write("%s\n" % json.dumps(payload)) stdin.flush() response = json.loads(stdout.readline()) if not response["ok"]: cherrypy.response.headers["x-slycat-message"] = response["message"] cherrypy.log.error("slycat.web.server.remote.py submit_batch", "cherrypy.HTTPError 400 %s" % response["message"]) raise cherrypy.HTTPError(400) # parses out the job ID jid = [int(s) for s in response["output"].split() if s.isdigit()][0] return {"filename": response["filename"], "jid": jid, "errors": response["errors"]} else: cherrypy.response.headers["x-slycat-message"] = "No Slycat agent present on remote host." cherrypy.log.error("slycat.web.server.remote.py submit_batch", "cherrypy.HTTPError 500 no Slycat agent present on remote host.") raise cherrypy.HTTPError(500)
[docs] def checkjob(self, jid): """ Submits a command to the slycat-agent to check the status of a submitted job to a cluster running SLURM. Parameters ---------- jid : int Job ID Returns ------- response : dict A dictionary with the following keys: jid, status, errors """ if self._agent is not None: stdin, stdout, stderr = self._agent payload = {"action": "checkjob", "command": jid} try: stdin.write("%s\n" % json.dumps(payload)) stdin.flush() except socket.error as e: delete_session(self._sid) raise socket.error('Socket is closed') response = json.loads(stdout.readline()) if not response["ok"]: cherrypy.response.headers["x-slycat-message"] = response["message"] cherrypy.log.error("slycat.web.server.remote.py checkjob", "cherrypy.HTTPError 400 %s" % response["message"]) raise cherrypy.HTTPError(400) # parses the useful information from job status #cherrypy.log.error("response state:%s" % response["output"]) status = { "state": response["output"] } return {"jid": response["jid"], "status": status, "errors": response["errors"], "logFile":response["logFile"]} else: cherrypy.response.headers["x-slycat-message"] = "No Slycat agent present on remote host." cherrypy.log.error("slycat.web.server.remote.py checkjob", "cherrypy.HTTPError 500 no Slycat agent present on remote host.") raise cherrypy.HTTPError(500)
[docs] def cancel_job(self, jid): """ Submits a command to the slycat-agent to cancel a running job on a cluster running SLURM. Parameters ---------- jid : int Job ID Returns ------- response : dict A dictionary with the following keys: jid, output, errors """ if self._agent is not None: stdin, stdout, stderr = self._agent payload = {"action": "cancel-job", "command": jid} stdin.write("%s\n" % json.dumps(payload)) stdin.flush() response = json.loads(stdout.readline()) if not response["ok"]: cherrypy.response.headers["x-slycat-message"] = response["message"] cherrypy.log.error("slycat.web.server.remote.py cancel_job", "cherrypy.HTTPError 400 %s" % response["message"]) raise cherrypy.HTTPError(400) return {"jid": response["jid"], "output": response["output"], "errors": response["errors"]} else: cherrypy.response.headers["x-slycat-message"] = "No Slycat agent present on remote host." cherrypy.log.error("slycat.web.server.remote.py cancel_job", "cherrypy.HTTPError 500 no Slycat agent present on remote host.") raise cherrypy.HTTPError(500)
[docs] def get_job_output(self, jid, path): """ Submits a command to the slycat-agent to fetch the content of the a job's output file from a cluster running SLURM. Note that the expected format for the output file is slurm-[jid].out. Parameters ---------- jid : int Job ID Returns ------- response : dict A dictionary with the following keys: jid, output, errors """ if self._agent is not None: stdin, stdout, stderr = self._agent payload = {"action": "get-job-output", "command": {"jid": jid, "path": path}} stdin.write("%s\n" % json.dumps(payload)) stdin.flush() response = json.loads(stdout.readline()) if not response["ok"]: cherrypy.response.headers["x-slycat-message"] = response["message"] cherrypy.log.error("slycat.web.server.remote.py get_job_output", "cherrypy.HTTPError 400 %s" % response["message"]) raise cherrypy.HTTPError(400) return {"jid": response["jid"], "output": response["output"], "errors": response["errors"]} else: cherrypy.response.headers["x-slycat-message"] = "No Slycat agent present on remote host." cherrypy.log.error("slycat.web.server.remote.py get_job_output", "cherrypy.HTTPError 500 no Slycat agent present on remote host.") raise cherrypy.HTTPError(500)
[docs] def get_user_config(self): """ Submits a command to the slycat-agent to fetch the content of a user's .slycatrc file in their home directory. Returns ------- response : dict A dictionary with the configuration values """ if self._agent is not None: stdin, stdout, stderr = self._agent payload = {"action": "get-user-config"} stdin.write("%s\n" % json.dumps(payload)) stdin.flush() response = json.loads(stdout.readline()) if not response["ok"]: cherrypy.response.headers["x-slycat-message"] = response["message"] cherrypy.log.error("slycat.web.server.remote.py get_user_config", "cherrypy.HTTPError 400 %s" % response["message"]) raise cherrypy.HTTPError(400) if "config" in response: return {"config": response["config"], "errors": response["errors"]} else: cherrypy.log.error("slycat.web.server.remote.py get_user_config", "cherrypy.HTTPError 500 no slycat rc key from agent response") raise cherrypy.HTTPError(500) else: cherrypy.response.headers["x-slycat-message"] = "No Slycat agent present on remote host." cherrypy.log.error("slycat.web.server.remote.py get_user_config", "cherrypy.HTTPError 500 no Slycat agent present on remote host.") raise cherrypy.HTTPError(500)
[docs] def set_user_config(self, config): """ Submits a command to the slycat-agent to set the content of a user's .slycatrc file in their home directory. Returns ------- response : dict """ if self._agent is not None: stdin, stdout, stderr = self._agent payload = {"action": "set-user-config", "command": {"config": config}} stdin.write("%s\n" % json.dumps(payload)) stdin.flush() response = json.loads(stdout.readline()) if not response["ok"]: cherrypy.response.headers["x-slycat-message"] = response["message"] cherrypy.log.error("slycat.web.server.remote.py set_user_config", "cherrypy.HTTPError 400 %s" % response["message"]) raise cherrypy.HTTPError(400) return {"errors": response["errors"]} else: cherrypy.response.headers["x-slycat-message"] = "No Slycat agent present on remote host." cherrypy.log.error("slycat.web.server.remote.py set_user_config", "cherrypy.HTTPError 500 no Slycat agent present on remote host.") raise cherrypy.HTTPError(500)
[docs] def run_remote_command(self, command): """ run a remote command from an HPC source running a slycat agent. the command could be things such as starting an hpc script or batch job or something as simple as moving files. the only requirement is that the script is in our list of trusted scripts. this_func()->calls agent_command_func()->which runs_shell_command() -> which launches_script()-> sends_response_to_agent()->sends_response_to_server() ->sends_status_response_to_client() Parameters ---------- command: json form of a command to be run { "scripts": //pre defined scripts that are registerd with the server [{ "script_name":"script_name", // key for the script lookup "parameters": [{key:value},...] // params that are fed to the script },...] "hpc": // these are the hpc commands that may be add for thing such as slurm { "is_hpc_job":bol, // determins if this should be run as an hpc job "parameters":[{key:value},...] // things such as number of nodes } } Returns ---------- obj: {"msg":"message from the agent", "error": boolean} """ # check for an agent if none available die if self._agent is None: cherrypy.response.headers["x-slycat-message"] = "No Slycat agent present on remote host." cherrypy.log.error("slycat.web.server.remote.py run_agent_function", "cherrypy.HTTPError 500 no Slycat agent present on remote host.") raise cherrypy.HTTPError(404, "no Slycat agent present on remote host.") # get the name of our slycat module on the hpc if command["hpc"]["is_hpc_job"] and "parameters" in command["hpc"]: command["hpc"]["parameters"]["module_name"] = None if "module-name" in slycat.web.server.config["slycat-web-server"]: command["hpc"]["parameters"]["module_name"] = slycat.web.server.config["slycat-web-server"]["module-name"] stdin, stdout, stderr = self._agent payload = { "action": "run-remote-command", "command": command } cherrypy.log.error("writing msg: %s" % json.dumps(payload)) stdin.write("%s\n" % json.dumps(payload)) stdin.flush() raw_response = stdout.readline() cherrypy.log.error("reading msg: %s" % raw_response) response = json.loads(raw_response) cherrypy.log.error("response msg: %s" % response) if not response["ok"]: cherrypy.response.headers["x-slycat-message"] = response["message"] cherrypy.log.error("agent response was not OK msg: %s" % response["message"]) cherrypy.log.error("slycat.web.server.remote.py run_agent_function", "cherrypy.HTTPError 400 %s" % response["message"]) raise cherrypy.HTTPError(status=400, message="run_agent_function response was not ok: %s" % response["message"]) return { "message": response["message"], "error": not response["ok"], "command": response["command"], "available_scripts": response["available_scripts"], "output": response["errors"], "errors": response["output"], "jid": response["jid"], "log_file_path": response["log_file_path"] }
[docs] def get_remote_job_status(self, jid): """ check of the status of a job running on an agent with a hostanemd session :param jid: job id :return: """ command = {"jid": jid} # check for an agent if none available die if self._agent is None: cherrypy.response.headers["x-slycat-message"] = "No Slycat agent present on remote host." cherrypy.log.error("slycat.web.server.remote.py run_agent_function", "cherrypy.HTTPError 500 no Slycat agent present on remote host.") raise cherrypy.HTTPError(404, "no Slycat agent present on remote host.") # get the name of our slycat module on the hpc command["module-name"] = None if "module-name" in slycat.web.server.config["slycat-web-server"]: command["module-name"] = slycat.web.server.config["slycat-web-server"]["module-name"] stdin, stdout, stderr = self._agent payload = { "action": "check-agent-job", "command": command } cherrypy.log.error("writing msg: %s" % json.dumps(payload)) stdin.write("%s\n" % json.dumps(payload)) stdin.flush() response = json.loads(stdout.readline()) cherrypy.log.error("response msg: %s" % response) if not response["ok"]: cherrypy.response.headers["x-slycat-message"] = response["message"] cherrypy.log.error("agent response was not OK msg: %s" % response["message"]) cherrypy.log.error("slycat.web.server.remote.py run_agent_function", "cherrypy.HTTPError 400 %s" % response["message"]) raise cherrypy.HTTPError(status=400, message="run_agent_function response was not ok: %s" % response["message"]) return response
[docs] def launch(self, command): """ Submits a single command to a remote location via the slycat-agent or SSH. Parameters ---------- command : string Command Returns ------- response : dict A dictionary with the following keys: command, output, errors """ if self._agent is not None: stdin, stdout, stderr = self._agent payload = {"action": "launch", "command": command} stdin.write("%s\n" % json.dumps(payload)) stdin.flush() response = json.loads(stdout.readline()) if not response["ok"]: cherrypy.response.headers["x-slycat-message"] = response["message"] cherrypy.log.error("slycat.web.server.remote.py launch", "cherrypy.HTTPError 400 %s" % response["message"]) raise cherrypy.HTTPError(400) return {"command": response["command"], "output": response["output"], "errors": response["errors"]} # launch via ssh... try: stdin, stdout, stderr = self._ssh.exec_command(command) response = {"command": command, "output": str(stdout.readlines())} return response except paramiko.SSHException as e: cherrypy.response.headers["x-slycat-message"] = str(e) cherrypy.log.error("slycat.web.server.remote.py launch", "cherrypy.HTTPError 500 %s" % str(e)) raise cherrypy.HTTPError(500) except Exception as e: cherrypy.response.headers["x-slycat-message"] = str(e) cherrypy.log.error("slycat.web.server.remote.py launch", "cherrypy.HTTPError 400 %s" % str(e)) raise cherrypy.HTTPError(400)
[docs] def browse(self, path, file_reject, file_allow, directory_reject, directory_allow): # Use the agent to browse. if self._agent is not None: stdin, stdout, stderr = self._agent command = {"action": "browse", "path": path} if file_reject is not None: command["file-reject"] = file_reject if file_allow is not None: command["file-allow"] = file_allow if directory_reject is not None: command["directory-reject"] = directory_reject if directory_allow is not None: command["directory-allow"] = directory_allow try: stdin.write("%s\n" % json.dumps(command)) stdin.flush() except socket.error as e: delete_session(self._sid) raise socket.error('Socket is closed') response = json.loads(stdout.readline()) if not response["ok"]: #cherrypy.log.error("response") #cherrypy.log.error(str(response)) cherrypy.response.headers["x-slycat-message"] = response["message"] raise cherrypy.HTTPError(400) #cherrypy.log.error("response") #cherrypy.log.error(str(response)) return {"path": response["path"], "names": response["names"], "sizes": response["sizes"], "types": response["types"], "mtimes": response["mtimes"], "mime-types": response["mime-types"]} # Use sftp to browse. try: names = [] sizes = [] types = [] mtimes = [] mime_types = [] for attribute in sorted(self._sftp.listdir_attr(path), key=lambda x: x.filename): filepath = os.path.join(path, attribute.filename) filetype = "d" if stat.S_ISDIR(attribute.st_mode) else "f" if filetype == "d": if directory_reject is not None and directory_reject.search(filepath) is not None: if directory_allow is None or directory_allow.search(filepath) is None: continue if filetype == "f": if file_reject is not None and file_reject.search(filepath) is not None: if file_allow is None or file_allow.search(filepath) is None: continue if filetype == "d": mime_type = "application/x-directory" else: mime_type = slycat.mime_type.guess_type(path)[0] names.append(attribute.filename) sizes.append(attribute.st_size) types.append(filetype) mtimes.append(datetime.datetime.fromtimestamp(attribute.st_mtime).isoformat()) mime_types.append(mime_type) response = {"path": path, "names": names, "sizes": sizes, "types": types, "mtimes": mtimes, "mime-types": mime_types} return response except Exception as e: cherrypy.log.error("Exception reading remote file %s: %s %s" % (path, type(e), str(e))) if str(e) == "Garbage packet received": cherrypy.response.headers["x-slycat-message"] = "Remote access failed: %s" % str(e) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 500 remote access failed: %s" % str(e)) raise cherrypy.HTTPError("500 Remote access failed.") if str(e) == "No such file": # Ideally this would be a 404, but we already use # 404 to handle an unknown sessions, and clients need to make the distinction. cherrypy.response.headers["x-slycat-message"] = "The remote file %s:%s does not exist." % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 the remote file %s:%s does not exist." % ( self.hostname, path)) raise cherrypy.HTTPError("400 File not found.") if str(e) == "Permission denied": # The file exists, but is not available due to access controls cherrypy.response.headers["x-slycat-message"] = "You do not have permission to retrieve %s:%s" % ( self.hostname, path) cherrypy.response.headers[ "x-slycat-hint"] = "Check the filesystem on %s to verify that your user has access " \ "to %s, and don't forget to set appropriate permissions on all " \ "the parent directories!" % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 you do not have permission to " "retrieve %s:%s. Check the filesystem on %s to verify that your " "user has access to %s, and don't forget to set appropriate permissions" " on all the parent directories." % ( self.hostname, path, self.hostname, path)) raise cherrypy.HTTPError("400 Access denied.") # Catchall cherrypy.response.headers["x-slycat-message"] = "Remote access failed: %s" % str(e) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 remote access failed: %s" % str(e)) raise cherrypy.HTTPError("400 Remote access failed.")
[docs] def write_file(self, path, data, **kwargs): ''' Todo: fill this section out ''' cache = kwargs.get("cache", None) project = kwargs.get("project", None) key = kwargs.get("key", None) # Sanity-check arguments. if cache not in [None, "project"]: cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 unknown cache type: %s." % cache) raise cherrypy.HTTPError("400 Unknown cache type: %s." % cache) if cache is not None: if project is None: cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 must specify project ID.") raise cherrypy.HTTPError("400 Must specify project id.") if key is None: cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 must specify cache key.") raise cherrypy.HTTPError("400 Must specify cache key.") # Use the agent to write a file. if self._agent is not None: stdin, stdout, stderr = self._agent try: cherrypy.log.error("Writing to the agent") stdin.write("%s\n" % json.dumps({"action": "write-file", "path": path, "data": base64.encodestring(data)})) stdin.flush() except socket.error as e: delete_session(self._sid) raise socket.error('Socket is closed') metadata = json.loads(stdout.readline()) cherrypy.log.error("reading %s" % metadata) if metadata["message"] == "Path must be absolute.": cherrypy.response.headers["x-slycat-message"] = "Remote path %s:%s is not absolute." % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 remote path %s:%s is not absolute." % ( self.hostname, path)) raise cherrypy.HTTPError("400 Path not absolute.") elif metadata["message"] == "No read permission.": cherrypy.response.headers["x-slycat-message"] = "You do not have permission to retrieve %s:%s" % ( self.hostname, path) cherrypy.response.headers[ "x-slycat-hint"] = "Check the filesystem on %s to verify that your user has" \ " access to %s, and don't forget to set appropriate permissions" \ " on all the parent directories!" % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 you do not have permission to " "retrieve %s:%s. Check the filesystem on %s to verify that" " your user has access to %s, and don't forget to set appropriate " "permissions on all the parent directories." % ( self.hostname, path, self.hostname, path)) raise cherrypy.HTTPError("400 Access denied.") elif metadata["message"] == "Path not found.": cherrypy.response.headers["x-slycat-message"] = "The remote file %s:%s does not exist." % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 the remote file %s:%s does not exist." % ( self.hostname, path)) raise cherrypy.HTTPError("400 File not found.") elif metadata["message"] == "Directory unreadable.": cherrypy.response.headers["x-slycat-message"] = "Remote path %s:%s is a directory." % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 can't read directory %s:%s." % (self.hostname, path)) raise cherrypy.HTTPError("400 Can't read directory.") elif metadata["message"] == "Access denied.": cherrypy.response.headers["x-slycat-message"] = "You do not have permission to retrieve %s:%s" % ( self.hostname, path) cherrypy.response.headers[ "x-slycat-hint"] = "Check the filesystem on %s to verify that your user has access" \ " to %s, and don't forget to set appropriate permissions on all" \ " the parent directories!" % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 you do not have permission to" " retrieve %s:%s. Check the filesystem on %s to verify " "that your user has access to %s, and don't forget to set" " appropriate permissions on all the parent directories." % ( self.hostname, path, self.hostname, path)) raise cherrypy.HTTPError("400 Access denied.") return metadata return "failed to write"
[docs] def get_file(self, path, **kwargs): cache = kwargs.get("cache", None) project = kwargs.get("project", None) key = kwargs.get("key", None) # Sanity-check arguments. if cache not in [None, "project"]: cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 unknown cache type: %s." % cache) raise cherrypy.HTTPError("400 Unknown cache type: %s." % cache) if cache is not None: if project is None: cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 must specify project ID.") raise cherrypy.HTTPError("400 Must specify project id.") if key is None: cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 must specify cache key.") raise cherrypy.HTTPError("400 Must specify cache key.") # Use sftp to retrieve a file. try: if stat.S_ISDIR(self._sftp.stat(path).st_mode): cherrypy.response.headers["x-slycat-message"] = "Remote path %s:%s is a directory." % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 can't read directory %s:%s." % (self.hostname, path)) raise cherrypy.HTTPError("400 Can't read directory.") content_type, encoding = slycat.mime_type.guess_type(path) if content_type is None: content_type = "application/octet-stream" my_file = self._sftp.file(path) my_file.prefetch() content = my_file.read(my_file.stat().st_size) if cache == "project": cache_object(project, key, content_type, content) cherrypy.response.headers["content-type"] = content_type return content except Exception as e: cherrypy.log.error("Exception reading remote file %s: %s %s" % (path, type(e), str(e))) if "Garbage packet received" in str(e): cherrypy.response.headers["x-slycat-message"] = "Remote access failed: %s" % str(e) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 500 remote access failed: %s" % str(e)) raise cherrypy.HTTPError("500 Remote access failed.") if "No such file" in str(e): # Ideally this would be a 404, but we already use # 404 to handle an unknown sessions, and clients need to make the distinction. cherrypy.response.headers["x-slycat-message"] = "The remote file %s:%s does not exist." % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 the remote file %s:%s does not exist." % ( self.hostname, path)) raise cherrypy.HTTPError("400 File not found.") if "Permission denied" in str(e): # The file exists, but is not available due to access controls cherrypy.response.headers["x-slycat-message"] = "You do not have permission to retrieve %s:%s" % ( self.hostname, path) cherrypy.response.headers[ "x-slycat-hint"] = "Check the filesystem on %s to verify that your user has access " \ "to %s, and don't forget to set appropriate permissions on all " \ "the parent directories!" % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 you do not have permission to " "retrieve %s:%s. Check the filesystem on %s to verify that your " "user has access to %s, and don't forget to set appropriate permissions" " on all the parent directories." % ( self.hostname, path, self.hostname, path)) raise cherrypy.HTTPError("400 Access denied.") # Catchall cherrypy.response.headers["x-slycat-message"] = "Remote access failed: %s" % str(e) cherrypy.log.error("slycat.web.server.remote.py get_file", "cherrypy.HTTPError 400 remote access failed: %s" % str(e)) raise cherrypy.HTTPError("400 Remote access failed.")
[docs] def get_image(self, path, **kwargs): content_type = kwargs.get("content-type", None) max_size = kwargs.get("max-size", None) max_width = kwargs.get("max-width", None) max_height = kwargs.get("max-height", None) cache = kwargs.get("cache", None) project = kwargs.get("project", None) key = kwargs.get("key", None) # Sanity-check arguments. if cache not in [None, "project"]: cherrypy.log.error("slycat.web.server.remote.py get_image", "cherrypy.HTTPError 400 unknown cache type: %s.") raise cherrypy.HTTPError("400 Unknown cache type: %s." % cache) if cache is not None: if project is None: cherrypy.log.error("slycat.web.server.remote.py get_image", "cherrypy.HTTPError 400 must specify project id.") raise cherrypy.HTTPError("400 Must specify project id.") if key is None: cherrypy.log.error("slycat.web.server.remote.py get_image", "cherrypy.HTTPError 400 must specify cache key.") raise cherrypy.HTTPError("400 Must specify cache key.") if not self._agent: cherrypy.response.headers["x-slycat-message"] = "No agent for %s." % (self.hostname) cherrypy.response.headers["x-slycat-hint"] = "Ask your system administrator to setup slycat-agent on %s" % ( self.hostname) cherrypy.log.error("slycat.web.server.remote.py get_image", "cherrypy.HTTPError 400 no agent for %s." % (self.hostname)) raise cherrypy.HTTPError("400 Agent required.") # Use the agent to retrieve an image. stdin, stdout, stderr = self._agent command = {"action": "get-image", "path": path} if content_type is not None: command["content-type"] = content_type if max_size is not None: command["max-size"] = max_size if max_width is not None: command["max-width"] = max_width if max_height is not None: command["max-height"] = max_height stdin.write("%s\n" % json.dumps(command)) stdin.flush() metadata = json.loads(stdout.readline()) if metadata["message"] == "Path must be absolute.": cherrypy.response.headers["x-slycat-message"] = "Remote path %s:%s is not absolute." % (self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_image", "cherrypy.HTTPError 400 remote path %s:%s is not absolute." % (self.hostname, path)) raise cherrypy.HTTPError("400 Path not absolute.") elif metadata["message"] == "Path not found.": cherrypy.response.headers["x-slycat-message"] = "The remote file %s:%s does not exist." % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_image", "cherrypy.HTTPError 400 the remote file %s:%s does not exist." % ( self.hostname, path)) raise cherrypy.HTTPError("400 File not found.") elif metadata["message"] == "Directory unreadable.": cherrypy.response.headers["x-slycat-message"] = "Remote path %s:%s is a directory." % (self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_image", "cherrypy.HTTPError 400 can't read directory for the remote path %s:%s." % ( self.hostname, path)) raise cherrypy.HTTPError("400 Can't read directory.") elif metadata["message"] == "Access denied.": cherrypy.response.headers["x-slycat-message"] = "You do not have permission to retrieve %s:%s" % ( self.hostname, path) cherrypy.response.headers[ "x-slycat-hint"] = "Check the filesystem on %s to verify that your user has access " \ "to %s, and don't forget to set appropriate permissions on all " \ "the parent directories!" % ( self.hostname, path) cherrypy.log.error("slycat.web.server.remote.py get_image", "cherrypy.HTTPError 400 you do not have permission to " "retrieve %s:%s. Check the filesystem on %s:%s to verify that " "your user has access to %s, and don't forget to set appropriate " "permissions on all the parent directories." % ( self.hostname, path, self.hostname, path, path)) raise cherrypy.HTTPError("400 Access denied.") content_type = metadata["content-type"] content = stdout.read(metadata["size"]) if cache == "project": cache_object(project, key, content_type, content) cherrypy.response.headers["content-type"] = content_type return content
[docs] def get_video(self, vsid): if not self._agent: cherrypy.response.headers["x-slycat-message"] = "No agent for %s." % (self.hostname) cherrypy.response.headers["x-slycat-hint"] = "Ask your system administrator to setup slycat-agent on %s" % ( self.hostname) cherrypy.log.error("slycat.web.server.remote.py get_video", "cherrypy.HTTPError 400 no agent for %s." % (self.hostname)) raise cherrypy.HTTPError("400 Agent required.") # Get the video from the agent. stdin, stdout, stderr = self._agent stdin.write("%s\n" % json.dumps({"action": "get-video", "sid": vsid})) stdin.flush() metadata = json.loads(stdout.readline()) sys.stderr.write("\n%s\n" % metadata) return slycat.web.server.streaming.serve(stdout, metadata["size"], metadata["content-type"])
[docs] def create_session(hostname, username, password, agent): """ Create a cached remote session for the given host. Parameters ---------- hostname : string Name of the remote host to connect via ssh. username : string Username for ssh authentication. password : string Password for ssh authentication. agent: bool Used to require / prevent agent startup. Returns ------- sid : string A unique session identifier. """ _start_session_cleanup_worker() client = cherrypy.request.headers.get("x-forwarded-for") sid = uuid.uuid4().hex try: ssh = slycat.web.server.ssh_connect(hostname=hostname, username=username, password=password) # Detect problematic startup scripts. stdin, stdout, stderr = ssh.exec_command("/bin/true") if stdout.read(): cherrypy.log.error("slycat.web.server.remote.py create_session", "cherrypy.HTTPError 500 Slycat can't connect because you " "have a startup script (~/.ssh/rc, ~/.bashrc, ~/.cshrc or similar)" " that writes data to stdout. Startup scripts should only write to " "stderr, never stdout - see sshd(8).") raise cherrypy.HTTPError( "500 Slycat can't connect because you have a startup script " "(~/.ssh/rc, ~/.bashrc, ~/.cshrc or similar) that writes data to stdout. " "Startup scripts should only write to stderr, never stdout - see sshd(8).") cherrypy.log.error("Created remote session for %s@%s from %s" % (username, hostname, client)) # Start sftp. sftp = ssh.open_sftp() # Optionally start an agent. remote_hosts = cherrypy.request.app.config["slycat-web-server"]["remote-hosts"] if agent is None: agent = hostname in remote_hosts and "agent" in remote_hosts[hostname] if agent: if hostname not in remote_hosts: cherrypy.log.error("slycat.web.server.remote.py create_session", "cherrypy.HTTPError 400 host %s not in allowed remote hosts." % hostname) raise cherrypy.HTTPError("400 Missing agent configuration.") if "agent" not in remote_hosts[hostname]: cherrypy.log.error("slycat.web.server.remote.py create_session", "cherrypy.HTTPError 400 missing agent configuration for host %s." % hostname) raise cherrypy.HTTPError("400 Missing agent configuration.") if "command" not in remote_hosts[hostname]["agent"]: cherrypy.log.error("slycat.web.server.remote.py create_session", "cherrypy.HTTPError 500 missing agent configuration" " for host %s: missing command keyword." % hostname) raise cherrypy.HTTPError("500 Missing agent configuration.") cherrypy.log.error("Starting agent executable for %s@%s with command: %s" % ( username, hostname, remote_hosts[hostname]["agent"]["command"])) stdin, stdout, stderr = ssh.exec_command(remote_hosts[hostname]["agent"]["command"]) cherrypy.log.error("Started agent") # Handle catastrophic startup failures (the agent process failed to start). try: startup = json.loads(stdout.readline()) except Exception as e: cherrypy.log.error("500 agent startup failed for host %s: %s." % (hostname, str(e))) cherrypy.log.error("slycat.web.server.remote.py create_session", "cherrypy.HTTPError 500 agent startup failed for host %s: %s." % ( hostname, str(e))) raise cherrypy.HTTPError("500 Agent startup failed: %s" % str(e)) # Handle clean startup failures (the agent process started, but reported an error). if not startup["ok"]: cherrypy.log.error("500 agent startup failed for host %s: %s." % (hostname, startup["message"])) cherrypy.log.error("slycat.web.server.remote.py create_session", "cherrypy.HTTPError 500 agent startup failed for host %s: %s." % ( hostname, startup["message"])) raise cherrypy.HTTPError("500 Agent startup failed: %s" % startup["message"]) agent = (stdin, stdout, stderr) with session_cache_lock: session_cache[sid] = Session(sid, client, username, hostname, ssh, sftp, agent) else: with session_cache_lock: session_cache[sid] = Session(sid, client, username, hostname, ssh, sftp) return sid except cherrypy.HTTPError as e: cherrypy.log.error("Agent startup failed for %s@%s: %s" % (username, hostname, e.status)) cherrypy.log.error("slycat.web.server.remote.py create_session", "Agent startup failed for %s@%s: %s" % (username, hostname, e.status)) raise except paramiko.AuthenticationException as e: cherrypy.log.error("Authentication failed for %s@%s: %s" % (username, hostname, str(e))) cherrypy.log.error("slycat.web.server.remote.py create_session", "cherrypy.HTTPError 403 authentication failed for %s@%s: %s." % ( username, hostname, str(e))) raise cherrypy.HTTPError("403 Remote authentication failed.") except Exception as e: cherrypy.log.error("Unknown exception for %s@%s: %s %s" % (username, hostname, type(e), str(e))) cherrypy.log.error("slycat.web.server.remote.py create_session", "cherrypy.HTTPError 500 unknown exception for %s@%s: %s %s." % ( username, hostname, type(e), str(e))) raise cherrypy.HTTPError("401 Remote connection failed: %s" % str(e))
[docs] def get_session(sid, calling_client=None): """ Return a cached remote session. If the session has timed-out or doesn't exist, raises a 404 exception. Parameters ---------- sid : string Unique session identifier returned by :func:`slycat.web.server.remote.create_session`. Returns ------- session : :class:`slycat.web.server.remote.Session` Session object that encapsulates the connection to a remote host. """ if calling_client is None: client = cherrypy.request.headers.get("x-forwarded-for") else: client=calling_client with session_cache_lock: _expire_session(sid) if sid in session_cache: session = session_cache[sid] #Only the originating client can access a session. if client != session.client: cherrypy.log.error("Client %s attempted to access remote session for %s@%s from %s" % ( client, session.username, session.hostname, session.client)) del session_cache[sid] cherrypy.log.error("slycat.web.server.remote.py get_session", "cherrypy.HTTPError 404: client %s attempted to " "access remote session for %s@%s from %s" % ( client, session.username, session.hostname, session.client)) raise cherrypy.HTTPError("404") if sid not in session_cache: raise cherrypy.HTTPError("404 not a session") session = session_cache[sid] session._accessed = datetime.datetime.utcnow() return session
[docs] def get_session_server(client, sid): """ Return a cached remote session. If the session has timed-out or doesn't exist, raises a 404 exception. Parameters ---------- sid : string Unique session identifier returned by :func:`slycat.web.server.remote.create_session`. Returns ------- session : :class:`slycat.web.server.remote.Session` Session object that encapsulates the connection to a remote host. :param client: """ with session_cache_lock: _expire_session(sid) if sid in session_cache: session = session_cache[sid] # Only the originating client can access a session. if client != session.client: cherrypy.log.error("Client %s attempted to access remote session for %s@%s from %s" % ( client, session.username, session.hostname, session.client)) del session_cache[sid] cherrypy.log.error("slycat.web.server.remote.py get_session", "cherrypy.HTTPError 404: client %s attempted to " "access remote session for %s@%s from %s" % ( client, session.username, session.hostname, session.client)) raise cherrypy.HTTPError("404") if sid not in session_cache: raise cherrypy.HTTPError("404 not a session") session = session_cache[sid] session._accessed = datetime.datetime.utcnow() return session
[docs] def check_session(sid): """ Return a true if session is active If the session has timed-out or doesn't exist, returns false Parameters ---------- sid : string Unique session identifier returned by :func:`slycat.web.server.remote.create_session`. Returns ------- boolean : """ with session_cache_lock: _expire_session(sid) response = True if sid not in session_cache: response = False if response: session = session_cache[sid] session._accessed = datetime.datetime.utcnow() return response
[docs] def delete_session(sid): """ Delete a cached remote session. Parameters ---------- sid : string, required Unique session identifier returned by :func:`slycat.web.server.remote.create_session`. """ with session_cache_lock: if sid in session_cache: session = session_cache[sid] cherrypy.log.error( "Deleting remote session for %s@%s from %s" % (session.username, session.hostname, session.client)) # try to close the session before we delete it try: session_cache[sid].close() except: pass del session_cache[sid]
def _expire_session(sid): """ Test an existing session to see if it is expired. Assumes that the caller already holds session_cache_lock. """ if sid in session_cache: now = datetime.datetime.utcnow() session = session_cache[sid] if now - session.accessed > slycat.web.server.config["slycat-web-server"]["remote-session-timeout"]: cherrypy.log.error( "Timing-out remote session for %s@%s from %s" % (session.username, session.hostname, session.client)) try: session_cache[sid].close() except: pass del session_cache[sid] def _session_monitor(): while True: #cherrypy.log.error("Remote session cleanup worker running.") with session_cache_lock: for sid in list( session_cache.keys()): # We make an explicit copy of the keys because we may be modifying the dict contents _expire_session(sid) cherrypy.log.error("Remote session cleanup worker finished.") time.sleep(datetime.timedelta(minutes=15).total_seconds()) def _start_session_cleanup_worker(): if _start_session_cleanup_worker.thread is None: cherrypy.log.error("Starting remote session cleanup worker.") _start_session_cleanup_worker.thread = threading.Thread(name="SSH Monitor", target=_session_monitor) _start_session_cleanup_worker.thread.daemon = True _start_session_cleanup_worker.thread.start() _start_session_cleanup_worker.thread = None