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 Set, Union, Optional

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.tasks import hosts
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(Exception): 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, username, password=None, prompt_for_password=False): """ Context manager for creating an ssh client. :param hostname: name of remote host :type hostname: str :param username: name of remote user :type username: str :param password: password for remote user :type password: str :return: ssh client connected as username@hostname :rtype: paramiko.SSHClient :raises paramiko.ssh_exception.AuthenticationException, paramiko.ssh_exception.SSHException """ 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.AuthenticationException, 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(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(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 from the server schrodinger specified by the local schrodinger.hosts file.\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.") 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, host_for_schrodinger=None): """ Retrieve the schrodinger environment variable for the given hostname. If 'host_for_schrodinger' is not given, defaults to first entry in the schrodinger.hosts file with matching hostname. :param hostname: server hostname from which to retrieve schrodinger value :type hostname: str :param host_for_schrodinger: host entry from schrodinger.hosts from which to retrieve schrodinger value (e.g. bolt_cpu) :type host_for_schrodinger: str :returns: schrodinger environment variable to use on the server :rtype: str :raises: RuntimeError if no host entry is found with the given host_for_schrodinger or hostname """ if host_for_schrodinger is not None: host_entry = hosts.get_host_by_name(host_for_schrodinger) return host_entry.schrodinger host_entries = jobcontrol.get_hosts() for host_entry in host_entries: if host_entry.getHost() == hostname: return host_entry.schrodinger raise RuntimeError( f"No host entry found for host: {hostname} in the schrodinger.hosts file." )
[docs]def get_cert_with_socket_auth(schrodinger: str, hostname: str, port: Union[int, str], user: str, socket_path: str, ssh_password: Optional[str] = None, host_for_schrodinger: Optional[str] = None): """ Generate a user certificate for job server using socket authentication through SSH. :param schrodinger: $SCHRODINGER environment variable, path to schrodinger suite :type schrodinger: str :param hostname: hostname for the job server to authenticate wtih :type hostname: str :param port: port for the job server to authenticate with; can be either an int or a str representation of an int :type port: int, str :param user: user for which to generate certificate, used as remote user for ssh if required. :type user: str :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. :type ssh_password: str :param host_for_schrodinger: host entry from schrodinger.hosts from which to retrieve schrodinger value (e.g. bolt_cpu) :type host_for_schrodinger: str :returns: True if a certificate is generated, otherwise an appropriate error. :rtype: bool :raises: paramiko.ssh_exception.AuthenticationException if an SSH connection could not be established. This could be because of an incorrect password, or because an existing SSH configuration was found without passwordless authentication to the specified remote hosts. :raises: paramiko.ssh_exception.SSHException if an SSH connection could not be established. This could be because no existing SSH configuration was found while no ssh_password was given. :raises: RuntimeError for any other failure """ if hostname_is_local(hostname): cert = _get_cert_from_socket_path(schrodinger, socket_path) return _add_cert(schrodinger, cert) server_schrodinger = _get_server_schrodinger(hostname, host_for_schrodinger) if not server_schrodinger: raise RuntimeError( "Could not retrieve valid $SCHRODINGER from the server.") 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) return _add_cert(schrodinger, cert) except paramiko.ssh_exception.AuthenticationException as error: err_msg = f"Could not SSH to remote server:\n'{error}'" raise RuntimeError(err_msg) from error
[docs]def get_cert(hostname: str, port: Union[int, str], user: str, schrodinger: Optional[str] = None, host_for_schrodinger: Optional[str] = None, ssh_password: Optional[str] = None, ldap_password: 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 host_for_schrodinger: host entry from schrodinger.hosts from which to retrieve schrodinger value (e.g. bolt_cpu) :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. :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) serverInfo = server.get_server_info(schrodinger, address) logger.debug(f"Server Info for {address} - {serverInfo}") cert_hostname_known = validate_server_for_auth(serverInfo) success = False if serverInfo.hasSocketAuth: try: success = get_cert_with_socket_auth( schrodinger, hostname, port, user, serverInfo.authSocketPath, ssh_password, host_for_schrodinger) except RuntimeError as error: err_msg = f"Socket authentication unsuccessful with error:\n'{error}'" if serverInfo.hasServerAuth: # log and continue logger.info(err_msg) else: raise SocketAuthenticationException(err_msg) from error if not success and serverInfo.hasServerAuth: logger.info(f"Attempting LDAP Authentication for user: {user}.",) success = get_cert_with_ldap(schrodinger, address, user, ldap_password) if not success: # If LDAP authentication fails interactively return None 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 elif serverInfo.hostname != hostname: logger.warning( f"The server was registered as {serverInfo.hostname}, as found on its TLS certificate. " f"This is not the same as the specified hostname, {hostname}.") registered_address = join_host_port(serverInfo.hostname, port) verify_cert(registered_address, schrodinger) return registered_address
[docs]def validate_server_for_auth(serverInfo: 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 serverInfo.has_authenticator(): raise AuthenticationException( "The requested server does not have any authentication methods available." ) if not serverInfo.hostname: return False try: socket.getaddrinfo(serverInfo.hostname, None) return True except OSError as e: raise AuthenticationException( "Refusing to authenticate with the server because the hostname on " f"the job server's TLS certificate, {serverInfo.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}.'" )
[docs]def configured_servers(): """ Check to see if the SCHRODINGER install has default job servers configured. :returns: a set of server addresses :rtype: set of str """ 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 {obj["address"] for obj in results} else: return set()
[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/run job_get_cert.py.")