"""
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
@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 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.")