Source code for schrodinger.job.cert

"""
Provide an interface for generating user certificates for job server.
Wraps '$SCHRODINGER/jsc cert' commands to create a single entrypoint.
The $SCHRODINGER environment variable is assumed to be an unescaped path.

Authentication can occur in two ways:

1) Using LDAP. In this case, the 'jsc ldap-get' command communicates the
   username and password to the job server using a gRPC method and saves the
   user certificate. The LDAP password can be submitted to the command either
   through an interactive commandline prompt or through piped stdin.

2) Using a Unix socket. In this case, the user must be on the server host to
   get a user certificate. The flow is as follows:

   a) The 'jsc get-auth-socket-path' command gets the path of the Unix socket
      from the server using a gRPC method.
   b) We then ssh to the server host and send a request over that Unix socket
      to retrieve a user certificate. (If the user is already on the same
      server host, we can skip ssh).
   c) That certificate is communicated back to the client machine over ssh,
      where a separate jsc command saves it.
"""

import contextlib
import getpass
import json
import os
import socket
import sys
import urllib.parse
from typing import Dict
from typing import List
from typing import Optional
from typing import Set
from typing import Union

import paramiko

from schrodinger.application.licensing.licadmin import hostname_is_local
from schrodinger.infra import mmjob
from schrodinger.job import jobcontrol
from schrodinger.job import server
from schrodinger.job.server import jsc
from schrodinger.utils import fileutils
from schrodinger.utils import log
from schrodinger.utils import sshconfig
from schrodinger.utils import subprocess

DEFAULT_JOB_SERVER_PORT = 8030

logger = log.get_logger("schrodinger.job.cert")


[docs]class AuthenticationException(Exception): pass
[docs]class SocketAuthenticationException(AuthenticationException): pass
[docs]class LDAPAuthenticationException(AuthenticationException): pass
[docs]class BadLDAPInputException(Exception): pass
@contextlib.contextmanager def _get_temp_keyfile_from_ppk(): """ Create an openSSH key file based on ppk file conversion. yields None if no file is created. """ if sys.platform != "win32": yield None else: ppk_file, _ = sshconfig.find_key_pair() if not os.path.exists(ppk_file): logger.debug( f"ppk_file '{ppk_file}' does not exist; no temp_keyfile generated" ) yield None else: with fileutils.tempfilename() as temp_file: try: sshconfig._convert_ppk_openssh(ppk_file, temp_file) yield temp_file except (OSError, RuntimeError) as e: logger.debug( f"Error in sshconfig._convert_ppk_openssh from ppk_file '{ppk_file}': {e}" ) yield None @contextlib.contextmanager def _get_ssh_client( hostname: str, username: Optional[str], password: Optional[str] = None, prompt_for_password: bool = False) -> Optional[paramiko.SSHClient]: """ Context manager for creating an ssh client. :param hostname: name of remote host :param username: name of remote user :param password: password for remote user :param prompt_for_password: whether to use getpass to retrieve an SSH password if unable to passwordless SSH :return: ssh client connected as username@hostname or None if localhost :raises: paramiko.ssh_exception.SSHException if SSH authentication fails """ if hostname_is_local(hostname): yield None return ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) with ssh: try: logger.debug(f"Attempting SSH connection as {username}@{hostname}") # Use an existing keyfile to attempt a passwordless SSH connection. # Paramiko will look up keyfiles in the default locations for OpenSSH keys. # On Windows, try to use an existing .ppk file by converting it to the # OpenSSH standard in a tempfile. with _get_temp_keyfile_from_ppk() as keyfile: ssh.connect(hostname, username=username, password=password, key_filename=keyfile) yield ssh except paramiko.ssh_exception.SSHException as error: if not prompt_for_password: raise error logger.error( f"SSH - Could not automatically connect to remote host with error: {error}", ) # Upon failure using keyfiles in an interactive prompt, ask the user for a password directly. password = getpass.getpass( f"SSH for socket authentication - Enter {username}@{hostname}'s password: " ) ssh.connect(hostname, username=username, password=password) yield ssh def _get_cert_from_socket_path(server_schrodinger: str, path: str, ssh=None): """ Uses socket authentication to generate a certificate for a job server. Since socket authentication is only supported for job servers running on unix systems, POSIX-style paths are used. Wraps '[ssh] $SCHRODINGER/jsc local-get [path]'. :param schrodinger: Job Server's $SCHRODINGER environment variable, path to jsc :type schrodinger: str :param path: path to the job server's authentication socket :type path: str :param ssh: SSH client with which to execute remote command. If None, the job server is assumed to be running locally, and subprocess.run will be used. :type ssh: paramiko.SSHClient :returns: user certificate for the job server, a JSON-formatted str :rtype: str :raises: RuntimeError """ cert_command_list = [ jsc(server_schrodinger), "cert", "local-get", "--output-file", "-", path ] # Use subprocess when the job server is on the same machine if ssh is None: proc = subprocess.run(cert_command_list, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) if proc.returncode: raise RuntimeError( f"Could not retrieve cert from local socket path: '{path}'. Ran command '{cert_command_list}' with output: '{proc.stdout}' with exit code: '{proc.returncode}.'" ) return proc.stdout cert_command = subprocess.list2cmdline(cert_command_list) _, out, err = ssh.exec_command(cert_command) output_list = out.readlines() if not output_list: errLines = err.readlines() for line in errLines: if "bash:" in line and "No such file or directory" in line: raise RuntimeError( "Could not find a jsc executable in the server SCHRODINGER installation.\n" f"Ran command '{cert_command}' with output: '{output_list}', with error: '{errLines}'\n" ) raise RuntimeError( f"Could not retrieve cert from remote socket path: '{path}'. Ran command {cert_command} with output: '{output_list}', with error: '{errLines}'\n" ) # The cert should be a single line return output_list[0]
[docs]def get_cert_with_ldap(schrodinger, address, user, ldap_password=None): """ Generates a user certificate job server at the given address. Wraps '$SCHRODINGER/jsc cert ldap-get --user [user] [address]' :param schrodinger: $SCHRODINGER environment variable for the current system :type schrodinger: str :param address: Server Address of the job server to authenticate with :type address: str :param user: Username to authenticate as. This must be the same as the username that will be used to submit jobs to the job server. :type user: str :param ldap_password: LDAP password for the given username. If None, the command is assumed to be in interactive mode. :type ldap_password: str :returns: True if authentication succeeds. False if authentication fails, or raises an exception if not in interactive mode. :rtype: bool :raises: BADLDAPInputException if ldap_password is None and sys.stdin is not a tty :raises: LDAPAuthenticationException if the authentication fails """ cert_get_command = [ jsc(schrodinger), "cert", "ldap-get", "--user", user, address ] if ldap_password is None: if not sys.stdin.isatty(): raise BadLDAPInputException( "LDAP Password required when input device is not a tty.") # Return rather than raising an exception because the "ldap-get" # command is already interactive and will print the error to stderr. # It may be better to collect a password ourselves and use the same # calls and returns as the maestro GUI code though. proc = subprocess.run(cert_get_command) return proc.returncode == 0 else: proc = subprocess.run(cert_get_command, input=ldap_password, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True) if proc.returncode: raise LDAPAuthenticationException( f"LDAP Authentication failed. Ran command: '{cert_get_command}' " f"with output: '{proc.stdout}' with exit code: '{proc.returncode}.'" ) return True
def _add_cert(schrodinger, cert): """ Adds the certificate to the user's collection. Wraps $SCHRODINGER/jsc cert add. :param schrodinger: $SCHRODINGER environment variable for the current system :type schrodinger: str :param cert: certificate to add :type cert: bytes :returns: True if the function succeeds, otherwise raises a RuntimeError :rtype: bool :raises: RuntimeError """ if not cert: raise RuntimeError("Cannot add empty cert.") add_cert_command = [jsc(schrodinger), "cert", "add", "-"] proc = subprocess.run(add_cert_command, input=cert.encode("ascii")) if proc.returncode: raise RuntimeError( f"Could not add certificate to collection. Ran command: '{add_cert_command}' with stderr: '{proc.stderr}' with exit code: '{proc.returncode}.'" ) return True def _get_server_schrodinger(hostname: str, server_info: server.ServerInfo) -> Optional[str]: """ Retrieve a schrodinger installation path for the given hostname. :param hostname: server hostname from which to retrieve schrodinger value :param server_info: The ServerInfo response of the server. :returns: schrodinger environment variable to use on the server :rtype: str """ if server_info.schrodingerInstallation: logger.debug( f"using server schrodinger of '{server_info.schrodingerInstallation}' given by the ServerInfo response" ) # Prioritize the SCHRODINGER installation known by the server return server_info.schrodingerInstallation conf_servers = _configured_servers() for conf_server in conf_servers: server_hostname, _ = hostname_and_port(conf_server["address"]) if server_hostname == hostname: server_schrodinger = conf_server["schrodinger"] logger.debug( f"using server schrodinger of '{server_schrodinger}' found in the server.json file" ) return server_schrodinger host_entries = jobcontrol.get_hosts() for host_entry in host_entries: # getHost returns either the server hostname if it's configured, # otherwise the host entry name. if host_entry.getHost() == hostname: server_schrodinger = host_entry.schrodinger logger.debug( f"using server schrodinger of '{server_schrodinger}' found in the schrodinger.hosts file" ) return server_schrodinger return None
[docs]def get_cert_with_socket_auth(schrodinger: str, hostname: str, user: str, socket_path: str, server_schrodinger: str, ssh_password: Optional[str] = None): """ Generate a user certificate for job server using socket authentication through SSH. :param schrodinger: $SCHRODINGER environment variable, path to schrodinger suite :param hostname: job server's hostname :param user: user for which to generate certificate, used as remote user for ssh if required. :param socket_path: the path on the server where the auth socket is located :param ssh_password: the SSH password for the given user. If None, the SSH password will be requested via a terminal prompt unless passwordless SSH is configured. :param server_schrodinger: for remote job servers, a path to the SCHRODINGER installation containing a "jsc" executable to communicate with the socket. :returns: True if a certificate is generated, otherwise an appropriate error. :rtype: bool :raises: RuntimeError for any other failure """ prompt_for_password = sys.stdin.isatty() try: with _get_ssh_client(hostname, user, ssh_password, prompt_for_password) as ssh: cert = _get_cert_from_socket_path(server_schrodinger, socket_path, ssh=ssh) except paramiko.ssh_exception.SSHException as error: err_msg = f"Could not SSH to remote server:\n'{error}'" raise RuntimeError(err_msg) from error return _add_cert(schrodinger, cert)
[docs]def get_cert(hostname: str, port: Union[int, str], user: str, *, schrodinger: Optional[str] = None, ssh_password: Optional[str] = None, ldap_password: Optional[str] = None, server_schrodinger: Optional[str] = None): """ Entrypoint to generate a user certificate for the requested server. A server can have one or both of unix socket authentication and LDAP authentication. Attempts unix socket authentication if enabled, otherwise falls back to LDAP authentication. :param hostname: hostname for the job server to authenticate wtih :param port: port for the job server to authenticate with :param user: user for which to generate certificate, used as remote user for ssh if required. :param schrodinger: $SCHRODINGER environment variable, path to schrodinger suite. If None, the current system's $SCHRODINGER environment variable will be used. :param ssh_password: the SSH password for the given user. If None, the SSH password will be requested via a terminal prompt unless passwordless SSH is configured. :param ldap_password: LDAP password for the given username. If left blank, the LDAP password will be requested in a terminal prompt. :param server_schrodinger: the server SCHRODINGER installation for socket authentication. If blank, this will be derived from available sources. :returns: hostname of the registered job server upon success :raises: BADLDAPInputException if ldap_password is left blank and sys.stdin is not a tty :raises: AuthenticationException if the authentication fails :raises: RuntimeError for any other failure """ if schrodinger is None: schrodinger = os.environ["SCHRODINGER"] address = join_host_port(hostname, port) server_info = server.get_server_info(schrodinger, address) logger.debug(f"Server Info for {address} - {server_info}") cert_hostname_known = validate_server_for_auth(server_info) did_authenticate = False # Have we successfully authenticated yet? if server_info.hasSocketAuth: try: if hostname_is_local(hostname): logger.debug( "Determined jobserver hostname is localhost; using subprocess instead of ssh" ) # If we're already on the server host and running this script, # the current SCHRODINGER works. server_schrodinger = schrodinger else: logger.debug( "Determined jobserver hostname is not localhost; attempting ssh for socket auth" ) if not server_schrodinger: server_schrodinger = _get_server_schrodinger( hostname, server_info) if not server_schrodinger: # This suggestion is specific to the cert script but still # seems worth including anyway since it won't be recoverable # for a registration GUI user. # As of PANEL-20339, this message will be dropped from the # GUI anyway and requires running the script to be seen. suggest_flags_msg = " (try specifying the -host-for-schrodinger or -server-schrodinger flag)" raise RuntimeError( "Unable to determine a SCHRODINGER installation path to use on the job server" + suggest_flags_msg) did_authenticate = get_cert_with_socket_auth( schrodinger, hostname, user, server_info.authSocketPath, server_schrodinger, ssh_password=ssh_password) except RuntimeError as error: err_msg = f"Socket authentication unsuccessful with error:\n'{error}'" if server_info.hasServerAuth: # hasServerAuth means the server uses LDAP authentication. # Since we can use that to proceed, log this error and continue logger.info(err_msg) else: raise SocketAuthenticationException(err_msg) from error if not did_authenticate and server_info.hasServerAuth: logger.info(f"Attempting LDAP Authentication for user: {user}.",) did_authenticate = get_cert_with_ldap(schrodinger, address, user, ldap_password) if not did_authenticate: # FIXME: This is a bit awkward and can probably be improved for more # consistency between the commandline script and the maestro # registration GUI. Consider this as part of JOBCON-7080. # In maestro, the above ldap auth will have already raised an exception. # Raise one here, too, for consistent behavior (and for get_jobserver_user_cert.py to pick it up). raise AuthenticationException if not cert_hostname_known: logger.debug( "Could not determine the certificate hostname from the jobserver. " "Run '$SCHRODINGER/jsc cert list' to verify the certificate has been added." ) return hostname if server_info.hostname != hostname: logger.warning( f"The server was registered as {server_info.hostname}, as found on its TLS certificate. " f"This is not the same as the specified hostname, {hostname}.") registered_address = join_host_port(server_info.hostname, port) verify_cert(registered_address, schrodinger) return registered_address
[docs]def validate_server_for_auth(server_info: server.ServerInfo) -> bool: """ Validates that it is possible to authenticate with the server. Otherwise, raises an error :returns: bool indicating if the server's certificate hostname is known. :raises: RuntimeError, AuthenticationException """ if not server_info.has_authenticator(): raise AuthenticationException( "The requested server does not have any authentication methods available." ) if not server_info.hostname: # NOTE: This situation should only arise against a job server that is # from 20-4 or older. We don't generally support forwards compatibility # from the client, and dropping this support would be nice for getting # rid of this unintuitive return value. logger.warning( "You are attempting to authenticate against an outdated jobserver release. Please use a client from the matching SCHRODINGER release." ) return False try: socket.getaddrinfo(server_info.hostname, None) return True except OSError as e: # This is often the case of a misconfigured job server raise AuthenticationException( "Refusing to authenticate with the server because the hostname on " f"the job server's TLS certificate, {server_info.hostname}, is not " "resolvable from this machine.\n" f"\tError:\t{e}\n" "Contact a server admin if you think this is a mistake.")
[docs]def has_cert_for_server(address, schrodinger=None): """ Check if the current user already has an existing cert for the given job server. :param address: Address of the Job Server :type address: str :returns: True if cert exists, False if not :rtype: bool """ return address in mmjob.get_registered_jobservers()
[docs]def verify_cert(address: str, schrodinger: Optional[str] = None): """ Verify that an rpc can be made using a TLS gRPC connection to the jobserver at the given address. """ if schrodinger is None: schrodinger = os.environ["SCHRODINGER"] verify_command = [jsc(schrodinger), "cert", "verify", address] proc = subprocess.run(verify_command, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) if proc.returncode != 0: raise RuntimeError( "Could not verify a gRPC connection with any existing certs.\n" f"Ran command '{verify_command}' with output: '{proc.stdout}' with exit code: '{proc.returncode}'" )
[docs]def remove_cert(address: str, schrodinger: Optional[str] = None): """ Removes the certificate to the user's collection. Wraps $SCHRODINGER/jsc cert add. :param address: The host:port of the server to remove. :type address: str :param schrodinger: $SCHRODINGER environment variable for the current system :type schrodinger: str :raises: RuntimeError if the executed command fails """ if schrodinger is None: schrodinger = os.environ["SCHRODINGER"] remove_cert_command = [jsc(schrodinger), "cert", "remove", address] proc = subprocess.run(remove_cert_command, capture_output=True, universal_newlines=True) if proc.returncode: raise RuntimeError( f"Could not remove a certificate from the collection. Ran command: '{remove_cert_command}' with stderr: '{proc.stderr}' with exit code: '{proc.returncode}.'" )
def _configured_servers() -> List[Dict[str, str]]: """ Return the default job servers configured in the SCHRODINGER installation. :returns: a list of server dicts with keys of "address" and "schrodinger". """ server_configuration = os.path.join(os.environ["SCHRODINGER"], "config", "server.json") if os.path.exists(server_configuration): with open(server_configuration, 'rb') as f: results = json.load(f) return results else: return []
[docs]def configured_servers() -> Set[str]: """ Check to see if the SCHRODINGER install has default job servers configured. :returns: a set of server addresses :rtype: set of str """ servers = _configured_servers() return {s["address"] for s in servers}
[docs]def servers_without_registration() -> Set[str]: """ Check to see if the current user is missing registration for default job servers. :returns: a set of server address that are lacking registration. """ conf_servers = configured_servers() # This will raise a runtime error if the json in the # jobserver.config file is not consistent. registered_servers = mmjob.get_registered_jobservers() return conf_servers - registered_servers
[docs]def hostname_and_port(addr): """ Get the hostname and port of the provided address. If no port is provided, return the default. :returns: a tuple of address and port :rtype: (str, int) """ parsed = urllib.parse.urlparse("//" + addr) if parsed.port is None: return parsed.hostname, DEFAULT_JOB_SERVER_PORT else: return parsed.hostname, parsed.port
[docs]def join_host_port(hostname: str, port: Union[str, int]) -> str: """ Join a hostname and port into a network address. Taken from the Go implementation of net.JoinHostPort. """ # Assume host is a literal IPv6 address if host has colons. if ':' in hostname: return f"[{hostname}]:{port}" return f"{hostname}:{port}"
if __name__ == '__main__': print("To generate a cert, use '$SCHRODINGER/jsc cert get'.")