#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2009 Fermi Research Alliance, LLC
# SPDX-License-Identifier: Apache-2.0
# Code and configuration files contributed by Brian Lin, OSG Software
"""Automatical renewal of proxies necessary for a glideinWMS frontend
"""
import configparser
import os
import pwd
import re
import subprocess
import sys
import tempfile
from glideinwms.lib import x509Support
from glideinwms.lib.util import safe_boolcomp
CONFIG = "/etc/gwms-frontend/proxies.ini"
DEFAULTS = {
"use_voms_server": "false",
"fqan": "/Role=NULL/Capability=NULL",
"frequency": "1",
"lifetime": "24",
"path_length": "20",
"rfc": "true",
"bits": "2048",
"owner": "frontend",
}
[docs]
class ConfigError(BaseException):
"""Catch-all class for errors in proxies.ini or system VO configuration"""
pass
[docs]
class Proxy:
"""Class for holding information related to the proxy"""
def __init__(self, cert, key, output, lifetime, uid=0, gid=0, rfc="true", pathlength="20", bits="2048"):
self.cert = cert
self.key = key
self.tmp_output_fd = tempfile.NamedTemporaryFile(dir=os.path.dirname(output), delete=False)
self.output = output
self.lifetime = lifetime
self.uid = uid
self.gid = gid
if str(rfc).lower() == "true":
self.rfc = True
else:
self.rfc = False
self.pathlength = pathlength
self.bits = bits
[docs]
def _voms_proxy_info(self, *opts):
"""Run voms-proxy-info. Returns stdout, stderr, and return code of voms-proxy-info"""
cmd = ["voms-proxy-info", "-file", self.output] + list(opts)
return _run_command(cmd)
[docs]
def write(self):
"""Move output proxy from temp location to its final destination"""
self.tmp_output_fd.flush()
os.fsync(self.tmp_output_fd)
self.tmp_output_fd.close()
os.chown(self.tmp_output_fd.name, self.uid, self.gid)
os.rename(self.tmp_output_fd.name, self.output)
[docs]
def timeleft(self):
"""Safely return the remaining lifetime of the proxy, in seconds (returns 0 if unexpected stdout)"""
return _safe_int(self._voms_proxy_info("-timeleft")[0])
[docs]
def actimeleft(self):
"""Safely return the remaining lifetime of the proxy's VOMS AC, in seconds (returns 0 if unexpected stdout)"""
return _safe_int(self._voms_proxy_info("-actimeleft")[0])
[docs]
def cleanup(self):
"""Cleanup temporary proxy files"""
os.remove(self.tmp_output_fd.name)
[docs]
@staticmethod
def voms_proxy_info(filename, *opts):
"""Run voms-proxy-info on a arbritary file. Returns stdout, stderr, and return code of voms-proxy-info
for any arbitrary file"""
cmd = ["voms-proxy-info", "-file", filename] + list(opts)
return _run_command(cmd)
[docs]
@classmethod
def timeleft_from_file(cls, filename):
"""Safely return the remaining lifetime of the proxy in the arbitrary file, in seconds
(returns 0 if unexpected stdout)"""
return _safe_int(cls.voms_proxy_info(filename, "-timeleft")[0])
[docs]
class VO:
"""Class for holding information related to VOMS attributes"""
def __init__(self, vo, fqan):
"""vo - name of the Virtual Organization. Case should match folder names in /etc/grid-security/vomsdir/
fqan - VOMS attribute FQAN with format "/vo/command" (/osg/Role=NULL/Capability=NULL) or
"command" (Role=NULL/Capability=NULL)
cert - path to VOMS server certificate used to sign VOMS attributes (for use with voms_proxy_fake)
key - path to key associated with the cert argument
uri - hostname and port of the VO's VOMS Admin Server, e.g. voms.opensciencegrid.org:15001
"""
self.name = vo
if fqan.startswith("/%s/" % vo):
pass
elif fqan.startswith("/Role="):
fqan = f"/{vo}{fqan}"
else:
raise ValueError(f'Malformed FQAN does not begin with "/{vo}/Role=" or "/Role=". Verify {CONFIG}.')
self.fqan = fqan
# intended argument for -voms option "vo:command" format, see voms-proxy-init man page
self.voms = ":".join([vo, fqan])
self.cert = None
self.key = None
self.uri = None
[docs]
def _safe_int(string_var):
"""Convert a string to an integer. If the string cannot be cast, return 0."""
try:
return int(string_var)
except ValueError:
return 0
[docs]
def _run_command(command):
"""Runs the specified command, specified as a list. Returns stdout, stderr and return code"""
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = proc.communicate()
return stdout, stderr, proc.returncode
[docs]
def parse_vomses(vomses_contents):
"""Parse the contents of a vomses file with the the following format per line:
"<VO ALIAS> " "<VOMS ADMIN HOSTNAME>" "<VOMS ADMIN PORT>" "<VOMS CERT DN>" "<VO NAME>"
And return two mappings:
1. Case insensitive VO name to their canonical versions
2. VO certificate DN to URI, i.e. HOSTNAME:PORT
Args:
vomses_contents(str): vomses file content
Returns:
dict, dict: lower case VO names to correct case, DN to "host:port"
"""
vo_info = re.findall(r'"[\w\.]+"\s+"([^"]+)"\s+"(\d+)"\s+"([^"]+)"\s+"([\w\.]+)"', vomses_contents, re.IGNORECASE)
# VO names are case-sensitive but we don't expect users to get the case right in proxies.ini
vo_names = {vo[3].lower(): vo[3] for vo in vo_info}
# A mapping between VO certificate subject DNs and VOMS URI of the form "<HOSTNAME>:<PORT>"
# We had to separate this out from the VO name because a VO could have multiple vomses entries
vo_uris = {vo[2]: vo[0] + ":" + vo[1] for vo in vo_info}
return vo_names, vo_uris
[docs]
def voms_proxy_init(proxy, voms_attr=None):
"""Create a proxy using voms-proxy-init, using the proxy information and optionally VOMS attribute.
Returns stdout, stderr, and return code of voms-proxy-init
"""
cmd = [
"voms-proxy-init",
"-debug",
"-cert",
proxy.cert,
"-key",
proxy.key,
"-out",
proxy.tmp_output_fd.name,
"-bits",
proxy.bits,
"-valid",
"%s:00" % proxy.lifetime,
]
if proxy.rfc:
cmd.append("-rfc")
if voms_attr:
# Some VOMS servers don't support capability/role/group selection so we just use the VO name when making
# the request. We don't handle this in the VO class because voms-proxy-fake requires the full VO name
# and command string.
if voms_attr.voms.endswith("/Role=NULL/Capability=NULL"):
voms = voms_attr.name
else:
voms = voms_attr.voms
# We specify '-order' because some European CEs care about VOMS AC order
# The '-order' option chokes if a Capability is specified but we want to make sure we request it
# in '-voms' because we're not sure if anything is looking for it
fqan = re.sub(r"\/Capability=\w+$", "", voms_attr.fqan)
cmd += ["-voms", voms, "-order", fqan]
return _run_command(cmd)
[docs]
def voms_proxy_fake(proxy, vo_info):
"""Create a valid proxy without contacting a VOMS Admin server. VOMS attributes are created from user config.
Returns stdout, stderr, and return code of voms-proxy-fake
"""
cmd = [
"voms-proxy-fake",
"--debug",
"-cert",
proxy.cert,
"-key",
proxy.key,
"-out",
proxy.tmp_output_fd.name,
"-bits",
proxy.bits,
"-hours",
proxy.lifetime,
"-voms",
vo_info.name,
"-hostcert",
vo_info.cert,
"-hostkey",
vo_info.key,
"-uri",
vo_info.uri,
"-fqan",
vo_info.fqan,
"-path-length",
proxy.pathlength,
]
if proxy.rfc:
cmd.append("-rfc")
return _run_command(cmd)
[docs]
def main():
"""Main entrypoint"""
config = configparser.ConfigParser(DEFAULTS)
config.read(CONFIG)
proxies = config.sections()
# Verify config sections
if proxies.count("COMMON") != 1:
raise ConfigError("there must be only one [COMMON] section in %s" % CONFIG)
if len([x for x in proxies if x.startswith("PILOT")]) < 1:
raise ConfigError("there must be at least one [PILOT] section in %s" % CONFIG)
# Proxies need to be owned by the 'frontend' user
try:
fe_user = pwd.getpwnam(config.get("COMMON", "owner"))
except KeyError:
raise RuntimeError("missing 'frontend' user")
# Load VOMS Admin server info for case-sensitive VO name and for faking the VOMS Admin server URI
vomses = os.getenv("VOMS_USERCONF", "/etc/vomses")
with open(vomses) as _:
vo_name_map, vo_uri_map = parse_vomses(_.read())
retcode = 0
# Proxy renewals
proxies.remove("COMMON") # no proxy renewal info in the COMMON section
for proxy_section in proxies:
proxy_config = dict(config.items(proxy_section))
proxy = Proxy(
proxy_config["proxy_cert"],
proxy_config["proxy_key"],
proxy_config["output"],
proxy_config["lifetime"],
fe_user.pw_uid,
fe_user.pw_gid,
proxy_config["rfc"],
proxy_config["path_length"],
proxy_config["bits"],
)
# Users used to be able to control the frequency of the renewal when they were instructed to write their own
# script and cronjob. Since the automatic proxy renewal cron/timer runs every hour, we allow the users to
# control this via the 'frequency' config option. If more than 'frequency' hours have elapsed in a proxy's
# lifetime, renew it. Otherwise, skip the renewal.
def has_time_left(time_remaining):
return int(proxy.lifetime) * 3600 - time_remaining < int(proxy_config["frequency"]) * 3600
if proxy_section == "FRONTEND":
if has_time_left(proxy.timeleft()):
print("Skipping renewal of %s: time remaining within the specified frequency" % proxy.output)
proxy.cleanup()
continue
stdout, stderr, client_rc = voms_proxy_init(proxy)
elif proxy_section.startswith("PILOT"):
if has_time_left(proxy.timeleft()) and has_time_left(proxy.actimeleft()):
print("Skipping renewal of %s: time remaining within the specified frequency" % proxy.output)
proxy.cleanup()
continue
vo_attr = VO(vo_name_map[proxy_config["vo"].lower()], proxy_config["fqan"])
if safe_boolcomp(proxy_config["use_voms_server"], True):
stdout, stderr, client_rc = voms_proxy_init(proxy, vo_attr)
else:
vo_attr.cert = proxy_config["vo_cert"]
vo_attr.key = proxy_config["vo_key"]
if Proxy.timeleft_from_file(vo_attr.cert) <= 0:
retcode = 1
print(
f"ERROR: Failed to renew proxy {proxy.output}: "
+ f"The VO certificate {vo_attr.cert} is expired. "
+ "Please verify your VO data installation."
)
proxy.cleanup()
continue
try:
vo_attr.uri = vo_uri_map[x509Support.extract_DN(vo_attr.cert)]
except KeyError:
retcode = 1
print(
f"ERROR: Failed to renew proxy {proxy.output}: "
+ f"Could not find entry in {vomses} for {vo_attr.cert}. "
+ "Please verify your VO data installation."
)
proxy.cleanup()
continue
stdout, stderr, client_rc = voms_proxy_fake(proxy, vo_attr)
else:
print(
f"WARNING: Unrecognized configuration section {proxy} found in {CONFIG}.\n"
+ "Valid configuration sections: 'FRONTEND' or 'PILOT'."
)
client_rc = -1
stderr = "Unrecognized configuration section '%s', renewal not attempted." % proxy_section
stdout = ""
if client_rc == 0:
proxy.write()
print(f"Renewed proxy from '{proxy.cert}' to '{proxy.output}'.")
else:
retcode = 1
# don't raise an exception here to continue renewing other proxies
print(f"ERROR: Failed to renew proxy {proxy.output}:\n{stdout}{stderr}")
proxy.cleanup()
return retcode
if __name__ == "__main__":
try:
sys.exit(main())
except (ConfigError, ValueError) as exc:
print("ERROR: " + str(exc))
sys.exit(1)