awips2/edexOsgi/com.raytheon.uf.tools.cli/impl/a2pgca
2017-09-25 20:59:08 -06:00

1493 lines
54 KiB
Python
Executable file

#!/usr/bin/env python
# a2pgca - A "mini CA" script for managing certificates used for accessing
# the database in AWIPS II.
#
# There are two main ways this script is used:
#
# At the NCF, it manages the root certificate and generates site-level CA
# certificates.
#
# At sites, it manages the server and database account certificates.
#
# Because the PostgreSQL software mandates keys not be group- or
# other-readable it is not possible to have certs/keys in a shared location.
# Each system user that connects to the database needs to have its own copy
# of the certs/keys. This script automates installing the certs/keys to
# application and user home directories taking access levels into account.
#
# The script publicly manages the following types of objects:
# sites - site-level CA certificates and initialization bundles
# dbusers - PostgreSQL database account identity certificates
# roles - lists of database accounts comprising access levels (user vs. admin)
# users - system users that need certs/keys; has a single role assigned
#
# Internally, there more general 'target' and 'identity' types that also
# manage cert/keys for the database server and applications.
#
# Modification History
#
# Name Date Comments
# ---------------------------------------------------------------------------
# David Friedman 2016-12-07 DR 19611 - Initial creation
# David Friedman 2016-12-22 DR 19637 - Support multiple DB servers.
from fnmatch import fnmatch
from getopt import GetoptError, getopt
from grp import getgrnam
import json
from os import chmod, chown, environ, geteuid, listdir, makedirs, mkdir, remove, rename, symlink
from os.path import basename, dirname, exists, isabs, isdir, isfile, join, normpath, splitext
from pwd import getpwall, getpwnam
from shutil import copyfile, rmtree
import subprocess
import sys
from tempfile import mkdtemp
from types import FunctionType
from zipfile import ZipFile
if sys.version_info < (2, 7):
# ZipFile is not a context manager in 2.6
class ZipFile(ZipFile):
def __enter__(self):
return self
def __exit__(self, type, value, traceback):
self.close()
def pout(text):
sys.stdout.write(text)
def pmsg(level, msg, fp=None):
fp = fp or sys.stderr
fp.write('%s: %s\n' % (level, msg))
def pinf(msg):
sys.stdout.write(msg + '\n')
def perr(msg):
pmsg('error', msg)
def pwrn(msg):
pmsg('warning', msg)
def pftl(msg):
pmsg('error', msg)
sys.exit(1)
class Fail(Exception):
pass
class UsageError(Fail):
pass
def make_safe_wrapper(name, f):
"""Return a "safe" version of the given function that reports
system errors instead of raising an exception."""
def safe_wrapper(*args, **kwargs):
try:
return f(*args, **kwargs)
except EnvironmentError as e:
perr('%s: %s' % (name, e))
return safe_wrapper
safe_chmod = make_safe_wrapper('chmod', chmod)
safe_remove = make_safe_wrapper('remove', remove)
safe_rename = make_safe_wrapper('rename', rename)
safe_symlink = make_safe_wrapper('symlink', symlink)
def safe_rmtree(path):
def handle_error(func, path, exc_info):
perr('%s: %s failed: %s' % (path, func.__name__, exc_info[1]))
rmtree(path, onerror=handle_error)
# As a precaution, rsync'ing to top-level directories and relative paths is
# not allowed.
def sanity_check_target_directory(path):
if not isabs(path) or len(normpath(path).split('/')) < 3:
raise Fail('unsafe destination directory: ' + path)
sub_commands = {}
def show_subcommand_usage(f):
prog_name_key = '#'
prog_name = basename(sys.argv[0])
cmd_text = prog_name + ' ' + f.name
usage_text = getattr(f, 'usage', None) or \
(' %s' % (prog_name_key,))
pout('%s - %s\n\nUsage:\n\n%s\n' % (f.name, f.desc, usage_text.replace(prog_name_key, cmd_text)))
def subcommand(arg=None, desc=None):
"""Decorate a function with an optional command name and
description."""
override_name = None
def impl(f):
name = override_name or f.func_name.replace('_','-')
sub_commands[name] = f
f.name = name
f.desc = desc
return f
if isinstance(arg, FunctionType):
return impl(arg)
else:
override_name = arg
return impl
def usage(text):
"""Decorate a function with a usage message and return a
wrapper function that catches usage errors and prints
the message."""
def impl(f):
def wrapper(*args, **kwargs):
try:
f(*args, **kwargs)
except (GetoptError, UsageError) as e:
perr(str(e))
show_subcommand_usage(wrapper)
f.usage = text
wrapper.usage = f.usage
wrapper.func_name = f.func_name
wrapper.name = getattr(f, 'name', None)
return wrapper
return impl
class Policy(object):
"""Represents target-specific policies for storing certificate files."""
def __init__(self, java_keys=False, create_dir=False, link_awips=False):
self.java_keys = java_keys
self.create_dir = create_dir
self.link_awips = link_awips
PUBLIC_DIR_PERM = int('755', 8)
PRIVATE_DIR_PERM = int('700', 8)
PUBLIC_FILE_PERM = int('644', 8)
PRIVATE_FILE_PERM = int('600', 8)
SYSTEM_PKI_DIR = '/etc/pki'
DEFAULT_PKI_SUBDIR = 'a2pgca'
VALIDITY_DAYS = 365 * 5
ORG_DN_PREFIX = '/O=AWIPS DB Auth'
BASELINE_DB_USERS = ('awips', 'awipsadmin', 'pguser', 'postgres')
UNPRIVILEGED_DB_USERS = ('awips','pguser')
TARGET_TYPE_POLICIES = {
'edex': Policy(java_keys=True, create_dir=True, link_awips=False),
'user': Policy(java_keys=True, create_dir=False, link_awips=True),
'server': Policy(java_keys=False, create_dir=False, link_awips=False),
}
JDBC_CRED_SUFFIXES = ('.crt', '.key', '.pk8')
LIBPQ_CRED_SUFFIXES = ('.crt', '.key')
ALL_CRED_SUFFIXES = JDBC_CRED_SUFFIXES
ALL_KEY_SUFFIXES = ('.key', '.pk8')
SERVER_ROLE = 'server'
INTERNAL_ROLES = (SERVER_ROLE,)
BASELINE_ROLES = ('server', 'user', 'admin')
BASELINE_ROLE_DB_USERS = {
'user': ('awips', 'pguser'),
'admin': BASELINE_DB_USERS
}
BASELINE_USERS = ('awips', 'fxa', 'ncf', 'root') # @fxa *is* allowed to be removed
DEFAULT_SITES = '''abq abr acr afc afg ajk akq alr aly ama apx arx awcn bcq
bgm bis bmx boi bou box bro btv buf byz cae car chs cle crp ctp cys ddc dlh
dmx dtx dvn eax ehu eka epz ewx ffc fgf fgz fsd fslc fwd fwr ggw gid gjt gld
grb grr gsp gum gyx hfon hgx hnx hpcn hun ict ilm iln ilx ind iwx jan jax jkl
key krf lbf lch lix lkn lmk lot lox lsx lub lwx lzk maf meg mfl mfr mhx mkx
mlb mob mpx mqt mrx mso msr mtr nhcn nhcr nhor nmtr nmtw ntca ntcb ntcc ntcd
oax ohx okx opcn opga orn osfw otx oun pah pbp pbz pdt phi pih pqr psr ptr
pub rah rev rha riw rlx rnk rsa sew sfmg sgf sgx shv sjt sju slc spcn sto str
swpn tae tar tbw tfx tir top tsa tua twc unr vef vhw vrh vuy wncf nwco
nwct'''.split()
def make_init_bundle_name(site_id):
return 'a2pgca-init-' + site_id + '.zip'
def init_empty_dir(path, mode=PUBLIC_DIR_PERM):
"""Create an empty directory if it does exist.
Fails if the directory cannot be created or if it already exists
and is not empty."""
if not exists(path):
makedirs(path, mode)
elif isdir(path) and listdir(path) == []:
return
else:
raise Fail('%s: already exists and is not an empty diretory' % (path, ))
def verify_dir(path):
if not isdir(path):
raise Fail('%s: does not exist or is not a directory' % (path,))
def verify_file(path):
if not isfile(path):
raise Fail('%s: does not exist or is not a regular file' % (path,))
def cat_files(src_paths, dest_path):
with open(dest_path, 'wb') as fd:
for path in src_paths:
with open(path, 'rb') as fs:
fd.write(fs.read())
def run_impl(*popenargs, **kwargs):
"""Executes a process via subprocess.Popen and its standard output.
Additional keyword arguments:
input -- If not None, is sent to the process via its standard
input.
echo_stderr_filtered -- If True, print standard error from the
process after it has completed. Removes the government
warning message.
echo_stdout -- If True, the process inherits standard output
(so that it will be echoed.)
stderr_to_stdout -- If True, pass stderr=subprocess.STDOUT to
Popen.
"""
proc_input = None
if 'input' in kwargs:
proc_input = kwargs.pop('input')
echo_stderr_filtered = False
if 'echo_stderr_filtered' in kwargs:
echo_stderr_filtered = kwargs.pop('echo_stderr_filtered')
echo_stdout = False
if 'echo_stdout' in kwargs:
echo_stdout = kwargs.pop('echo_stdout')
stderr_to_stdout = False
if 'stderr_to_stdout' in kwargs:
stderr_to_stdout = kwargs.pop('stderr_to_stdout')
if 'stdout' in kwargs or 'stderr' in kwargs:
raise ValueError('stdout/stderr argument not allowed, it will be overridden.')
with open('/dev/null', 'rb') as dev_null:
stdin = subprocess.PIPE if proc_input is not None else dev_null
stdout = None if echo_stdout else subprocess.PIPE
stderr = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
process = subprocess.Popen(stdin=stdin, stdout=stdout,
stderr=stderr, *popenargs, **kwargs)
output, err = process.communicate(proc_input)
if stderr_to_stdout:
err = output
retcode = process.poll()
if echo_stderr_filtered:
filtered = False
initial = True
for line in err.split('\n'):
if initial and not line:
continue
elif line.strip() == '**WARNING**WARNING**WARNING**':
filtered = not filtered
elif not filtered:
initial = False
pout(line + '\n')
err = ''
if retcode:
cmd = kwargs.get("args")
if cmd is None:
cmd = popenargs[0]
e = subprocess.CalledProcessError(retcode, cmd)
e.output = err
raise e
return output
def run(cmd, **kwargs):
"""Run a process, catching errors and re-raising them as Fail
exceptions."""
try:
run_impl(cmd, **kwargs)
except subprocess.CalledProcessError as e:
str_output = str(e.output).rstrip('\n')
tail = '\n---\n%s\n---' % (str_output,)
raise Fail('running "%s": failed%s' % (' '.join(cmd), tail))
except EnvironmentError as e:
raise Fail('running "%s": %s' % (' '.join(cmd), e))
except KeyboardInterrupt:
raise Fail('running "%s": interrupted' % (' '.join(cmd),))
def generate_cert(cn, output_prefix, ca_prefix=None, ca=False, pathlen=None):
"""Generate an x509 certificate and key.
Return output_prefix.
output_prefix -- Base path of the output files to which ".crt", etc.
will be appended.
ca_prefix -- Base path of CA cert/key files used to sign the
certificate.
ca -- If True, create a CA certificate
pathlen -- Specifies the maximum number of levels of CA certificates
that can be derived from this one.
"""
ext_path = None
req_path = output_prefix + '.req'
key_path = output_prefix + '.key'
crt_path = output_prefix + '.crt'
pk8_path = output_prefix + '.pk8'
def cleanup():
for path in (ext_path, req_path, key_path, crt_path, pk8_path):
if path and exists(path):
safe_remove(path)
try:
if ca:
ext_path = output_prefix + '.ext'
with open(ext_path, 'w') as f:
# The following statement uses defaults copied from openssl.cnf
f.write('''subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always,issuer
basicConstraints=CA:true''')
# Add path length constraint if specified
if pathlen:
f.write(',pathlen:%d' % (pathlen,))
f.write('\n')
run(['openssl', 'req', '-new', '-nodes', '-subj', g.format_dn(cn),
'-out', req_path, '-keyout', key_path])
chmod(key_path, PRIVATE_FILE_PERM)
cmd = ['openssl', 'x509', '-req', '-in', req_path, '-out', crt_path,
'-days', str(VALIDITY_DAYS)]
if ext_path:
cmd += ['-extfile', ext_path]
if ca_prefix:
cmd += ['-CA', ca_prefix + '.crt', '-CAkey', ca_prefix + '.key', '-CAcreateserial']
else:
cmd += ['-signkey', key_path]
run(cmd)
if not isfile(crt_path):
raise Fail("certificate file '%s' was not generated" % (crt_path,))
safe_remove(req_path)
if ext_path:
safe_remove(ext_path)
if not ca:
run(['openssl', 'pkcs8', '-nocrypt', '-in', key_path, '-topk8',
'-outform', 'der', '-out', pk8_path])
chmod(pk8_path, PRIVATE_FILE_PERM)
except Exception as e:
cleanup()
raise Fail("generating %s.*: %s" % (output_prefix, e))
except:
cleanup()
raise
return output_prefix
def self_sign_cert(cn, output_prefix, pathlen=None):
return generate_cert(cn, output_prefix, ca=True, pathlen=pathlen)
def make_ca_cert(cn, ca_prefix, output_prefix, pathlen=None):
return generate_cert(cn, output_prefix, ca_prefix=ca_prefix, ca=True, pathlen=pathlen)
def make_ident_cert(cn, ca_prefix, output_prefix):
return generate_cert(cn, output_prefix, ca_prefix=ca_prefix)
def make_spec(ttype, name):
return '%s:%s' % (ttype, name)
def get_from_map(mapping, key, ttype):
try:
return mapping[key]
except KeyError:
raise Fail("unknown %s '%s'" % (ttype, key))
def delete_from_map(mapping, key, ttype):
try:
del mapping[key]
except KeyError:
raise Fail("unknown %s '%s'" % (ttype, key))
def resolve_target_locations(target):
"""Return the expanded list of the storage locations of the given
target.
If the target is of type 'user' and no locations are specified
or there is an entry with and empty host and path, return an entry
for the user's $HOME/.postrgresql on the local system.
If the host part of any storage location contains environment
variable references, expand them.
Return a list of (host, path).
"""
result = []
specs = target.location_specs
if target.type == 'user' and not specs:
specs = [':']
for spec in specs:
host_specs, path = spec.split(':', 1)
if target.type == 'user' and not path:
try:
path = getpwnam(target.name).pw_dir
except (KeyError, AttributeError):
perr('user %s: can not determine home directory' % (target.name,))
continue
path = join(path, '.postgresql')
if not path:
perr("target '%s': location spec missing destination directory" % (target.get_spec()))
continue
if not host_specs.strip():
host_list = ['']
else:
hosts = set()
for hs in host_specs.split():
exclude = False
if hs[0:1] == '!':
exclude = True
hs = hs[1:]
if hs[0:1] == '$':
# Only one level of expansion supported
name = hs[1:]
hs = environ.get(name)
if hs:
hs = hs.split()
else:
# Do not report missing XT_WORKSTATIONS since that will
# go away soon.
if hs is None and name != "XT_WORKSTATIONS":
pwrn('environment variable %s not set' % (name,))
continue
else:
hs = [ hs ]
for host in hs:
if not exclude:
hosts.add(host)
else:
for test_host in list(hosts):
if fnmatch(test_host, host):
hosts.discard(test_host)
host_list = list(hosts)
host_list.sort()
for host in host_list:
result.append((host, path))
return result
class Global(object):
"""Contains global variables for the script.
Members:
data_dir -- Path where all CA data is stored.
state -- Object state. See State class.
site_id -- $SITE_IDENTIFIER value for the site.
idents -- Map of all database access and identity certificate/key
files that currently exist.
dn_prefix -- Prefix for the openssl -subj argument. The common name
attribute is appended to this.
"""
def __init__(self):
self.data_dir = join(SYSTEM_PKI_DIR, DEFAULT_PKI_SUBDIR)
self.state = None
self.site_id = environ.get('SITE_IDENTIFIER')
self.idents = {}
self.dn_prefix = ORG_DN_PREFIX + '/OU=DBauth'
def get_data_dir(self):
if not self.data_dir:
pftl('data directory not set to a valid value')
return self.data_dir
def get_ca_dir(self):
return join(self.get_data_dir(), 'ca')
def get_ident_dir(self):
return join(self.get_data_dir(), 'ident')
def get_state_dir(self):
return join(self.get_data_dir(), 'state')
def get_ca_prefix(self):
return join(self.get_ca_dir(), 'ca')
def get_root_bundle(self):
return join(self.get_data_dir(), 'root.crt')
def set_dn_prefix(self, dn_prefix):
self.dn_prefix = dn_prefix
if self.state:
self.state.dn_prefix = dn_prefix
def format_dn(self, cn):
return self.dn_prefix + '/CN=' + cn
def create_data_dir(self):
init_empty_dir(self.get_data_dir())
try:
mkdir(self.get_ca_dir(), PRIVATE_DIR_PERM)
mkdir(self.get_ident_dir(), PRIVATE_DIR_PERM)
mkdir(self.get_state_dir(), PUBLIC_DIR_PERM)
except:
safe_rmtree(self.get_data_dir())
raise
def verify_data_dir(self):
verify_dir(self.get_data_dir())
def verify_ca(self):
self.verify_data_dir()
pfx = self.get_ca_prefix()
verify_file(pfx + '.crt')
verify_file(pfx + '.key')
verify_file(self.get_root_bundle())
return pfx
def verify_model(self, model):
self.verify_ca()
self.load_state()
if self.state.model != model:
raise Fail('This command can only be used with a %s CA' % (model,))
def verify_role(self, role):
if role not in self.get_state().roles:
raise Fail("Invalid role '%s'" % (role,))
def get_site_id(self):
if not self.site_id:
raise Fail('site identifier not specified (maybe set SITE_IDENTIFIER?)')
return self.site_id
def get_state(self):
return self.state
def set_state(self, state):
self.state = state
def get_state_file(self):
return join(self.get_state_dir(), 'state.json')
def load_state(self):
if self.state is not None:
return
s = State()
s.load(self.get_state_file())
upgraded = self.upgrade_state(s)
self.state = s
if upgraded:
pinf('Upgraded CA data directory')
self.store_state()
if s.dn_prefix is not None:
self.dn_prefix = s.dn_prefix
# Identities are stored implicitly as credential files
idents = {}
ident_dir = self.get_ident_dir()
for itype in listdir(ident_dir):
sub_dir = join(ident_dir, itype)
for name in listdir(sub_dir):
iname = splitext(name)[0]
ident = Identity(itype, iname)
idents[ident.get_spec()] = ident
self.idents = idents
def store_state(self):
if self.state is not None:
self.state.store(self.get_state_file())
def upgrade_state(self, s):
if s.version == 1:
try:
target = s.targets.pop('server:server')
target.name = 'dx1f'
s.targets[target.get_spec()] = target
except KeyError:
pass # ignore
try:
s.roles[SERVER_ROLE].remove('server:server')
except (KeyError, ValueError):
pass # ignore
src_pfx = join(self.get_ident_dir(), 'server', 'server')
dst_pfx = join(self.get_ident_dir(), 'server', 'dx1f')
for sfx in ALL_CRED_SUFFIXES:
if exists(src_pfx + sfx):
safe_rename(src_pfx + sfx, dst_pfx + sfx)
s.version = 2
return True
elif not s.version or s.version < 1 or s.version > 2:
pftl('Unknown CA data directory version %s' % (s.version,))
return False
def has_ident(self, ident):
return ident.get_spec() in self.idents
def add_ident(self, ident):
self.idents[ident.get_spec()] = ident
def get_idents(self):
return self.idents.values()
def get_policy_for_target(self, target):
return TARGET_TYPE_POLICIES[target.type]
class Target(object):
"""Describes a consumer of certificate/key files, including which
files should be stored and where to store them.
Targets are referenced externally by a "type:name" spec string.
Each target may have zero or more storage location specs. (For
user targets, no explicit location specs means to store in the
user's $HOME/.postgresql directory.) Each spec is of the
form "host list:path". If the host list is empty, it indicates
rsync should operate on the local system. The host list can
reference environment variable with the form "$VARNAME".
"""
def __init__(self, ini=None):
self.type = None
self.name = None
self.role = None
self.location_specs = []
self.owner = None
if ini:
self.__dict__.update(ini)
def get_spec(self):
return make_spec(self.type, self.name)
def __str__(self):
return self.get_spec()
class Identity(object):
"""Describes a certificate/key file that will be used by users and
applications.
Identiy objects are referenced externally by a "type:name" spec string.
"""
def __init__(self, itype, name):
self.type = itype
self.name = name
def get_spec(self):
return make_spec(self.type, self.name)
def __repr__(self):
return self.get_spec()
def __str__(self):
return self.get_spec()
class State(object):
"""Represents object state of the CA, excluding existing certificate/key files.
Members:
roles -- Map of role names to list of identity specs.
targets -- Map of target spec names to Target objects.
version -- State file format version.
model -- Indicates the role of the CA (NCF or site.)
dn_prefix -- Stored version of Global.dn_prefix
backups -- List of hosts to rsync the CA data directory to.
"""
def __init__(self):
self.roles = {}
self.targets = {}
self.version = 2
self.model = None
self.dn_prefix = None
self.backups = []
def load(self, path):
self.version = None
with open(path, 'r') as f:
self.__dict__.update(json.load(f))
self.targets = dict([ (target.get_spec(), target) for target in
[ Target(ini) for ini in self.targets ] ])
def store(self, path):
with open(path, 'w') as f:
d = self.__dict__.copy()
d['targets'] = [ target.__dict__ for target in self.targets.values() ]
json.dump(d, f)
def add_role(self, role, ident_specs):
if role in self.roles:
raise Fail("role '%s' already exists" % (role,))
self.roles[role] = list(ident_specs)
def remove_role(self, role):
delete_from_map(self.roles, role, 'role')
def get_role_idents(self, role):
return [ Identity(*spec.split(':', 1)) for spec in
get_from_map(self.roles, role, 'role') ]
def get_roles(self):
return self.roles.copy()
def set_role_ident_specs(self, role, ident_specs):
get_from_map(self.roles, role, 'role') # check existence
self.roles[role] = ident_specs
def add_target(self, ttype, name, role, location_specs, owner=None):
if ttype not in TARGET_TYPE_POLICIES:
raise Fail("target type '%s' is not valid" % (ttype,))
target = Target()
target.type = ttype
target.name = name
target.role = role
target.location_specs = location_specs
target.owner = owner
target_spec = target.get_spec()
if target_spec in self.targets:
raise Fail("target '%s' already exists" % (target_spec))
self.targets[target_spec] = target
def remove_target(self, ttype, name):
delete_from_map(self.targets, make_spec(ttype, name), 'target')
def has_target(self, ttype, name):
spec = make_spec(ttype, name)
return spec in self.targets
def get_target(self, ttype, name):
spec = make_spec(ttype, name)
try:
return self.targets[spec]
except KeyError:
raise Fail("unknown target '%s'" % (spec,))
def get_targets(self):
return self.targets.values()
g = Global()
def add_ident_impl(itype, name, cn=None):
ident = Identity(itype, name)
if g.has_ident(ident):
raise Fail("identity '%s' already exists" % (ident,))
if cn is None:
cn = name
d = join(g.get_ident_dir(), itype)
if not exists(d):
makedirs(d, PRIVATE_DIR_PERM)
make_ident_cert(cn, g.get_ca_prefix(), join(d, name))
g.add_ident(ident)
def remove_ident_impl(itype, iname):
found = False
ident = Identity(itype, iname)
if g.has_ident(ident):
d = join(g.get_ident_dir(), itype)
if isdir(d):
for name in listdir(d):
if name == iname or splitext(name)[0] == iname:
found = True
safe_remove(join(d, name))
if not found:
pwrn("no existing identity '%s'" % (ident,))
@subcommand(desc='Initialize the CA data directory for use at the NCF')
@usage(''' # [options]
Options:
-n {string} Override default distinguished name attributes
-O Do not generate certificates for operational sites
''')
def init_ncf(argv):
generate_operational_sites = True
dn_prefix = ORG_DN_PREFIX + '/OU=NCF'
opts, _ = getopt(argv, 'n:O')
for k, v in opts:
if k == '-n':
dn_prefix = v
elif k == '-O':
generate_operational_sites = False
g.create_data_dir()
try:
ca_dir = g.get_ca_dir()
g.set_state(State())
g.set_dn_prefix(dn_prefix)
g.get_state().model = 'ncf'
cn = 'ca-root'
root = self_sign_cert(cn, join(ca_dir, 'ncf-ca-root'))
ca = make_ca_cert('ncf-ca', root, g.get_ca_prefix(), pathlen=1)
cat_files([ca + '.crt', root + '.crt'], g.get_root_bundle())
g.store_state()
except:
safe_rmtree(g.get_data_dir())
raise
pinf('successfully created CA data directory in %s' % (g.get_data_dir(),))
backup_impl()
if generate_operational_sites:
pinf('generating certificates for %s sites...' % (len(DEFAULT_SITES),))
site_cmd(['-a'] + list(DEFAULT_SITES))
@subcommand(desc='Initialize the CA data directory for use at an AWIPS site')
@usage(''' # [ -b {file} | -s ] [-h {host}]
Options:
-b {file} Initialize with the given bundle [/root/a2pgca-init-{site}.zip]
-s Initialize using a self-signed certificate
-h {host} Specify the database host name that applications use to connect [dx1f]''')
def init_site(argv):
bundle = None
db_host_name = 'dx1f'
self_signed = False
opts, _ = getopt(argv, 'b:h:s')
for k, v in opts:
if k == '-b':
bundle = v
elif k == '-h':
if not v:
raise Fail('database server host name must not be empty')
db_host_name = v
elif k == '-s':
self_signed = True
if bundle and self_signed:
raise UsageError('Can not specify both initialization bundle and self-signed mode')
g.create_data_dir()
try:
g.set_state(State())
g.set_dn_prefix(ORG_DN_PREFIX + '/OU=Site ' + g.get_site_id().upper())
st = g.get_state()
st.model = 'site'
if not self_signed and bundle is None:
name = make_init_bundle_name(g.get_site_id())
try:
home = getpwnam('root').pw_dir
except Exception as e:
home = None
directories = (SYSTEM_PKI_DIR, home)
for directory in directories:
if directory:
bundle = join(directory, name)
if exists(bundle):
break
if not bundle or not exists(bundle):
raise Fail("initialization bundle %s not found in any of %s" % (name, directories))
if self_signed:
cn = 'ca-root-' + g.get_site_id()
ca = self_sign_cert(cn, g.get_ca_prefix(), pathlen=0)
cat_files([ca + '.crt'], g.get_root_bundle())
else:
ca = g.get_ca_prefix()
with ZipFile(bundle, 'r') as fs:
for name, path in (
('ca.crt', ca + '.crt'),
('ca.key', ca + '.key'),
('root.crt', g.get_root_bundle())):
with open(path, 'wb') as fd:
fd.write(fs.read(name))
try:
run(['openssl', 'verify', '-CAfile', g.get_root_bundle(),
g.get_ca_prefix() + '.crt'], stderr_to_stdout=True)
except Exception as e:
raise Fail('Failed to validate site CA certificate: %s' % (e,))
has_rax_db = environ.get('SITE_TYPE') == 'rfc'
for name in BASELINE_DB_USERS:
add_ident_impl('dbuser', name)
add_ident_impl('server', db_host_name, cn=db_host_name)
if has_rax_db:
add_ident_impl('server', 'ax', cn='ax')
st.add_role(SERVER_ROLE, [])
st.add_role('user', ['dbuser:' + u for u in BASELINE_ROLE_DB_USERS['user']])
st.add_role('admin', ['dbuser:' + u for u in BASELINE_ROLE_DB_USERS['admin']])
st.add_target('server', 'dx1f', 'server', ['dx1f:/awips2/data'], owner='awips')
if has_rax_db:
st.add_target('server', 'ax', 'server', ['ax:/awips2/data'], owner='awips')
st.add_target('edex', 'edex', 'admin', ['$DX_SERVERS !dx[12]* $PX_SERVERS $COMMS_PROCESSORS:/awips2/edex/conf/db/auth'], owner='awips')
st.add_target('user', 'awips', 'admin', [])
st.add_target('user', 'ncfuser', 'admin', [])
st.add_target('user', '@fxalpha', 'user', [])
# /awips/fxa is not on CPs. root should not need DB access there either.
most_hosts = '$DX_SERVERS $PX_SERVERS $LX_WORKSTATIONS $XT_WORKSTATIONS'
st.add_target('user', 'root', 'admin', [most_hosts + ':'])
st.add_target('user', 'fxa', 'admin', [most_hosts + ':'])
st.backups += [ pfx + '-' + g.get_site_id() for pfx in ('dx1', 'dx2') ]
g.store_state()
except:
safe_rmtree(g.get_data_dir())
raise
pinf('successfully created CA data directory in %s' % (g.get_data_dir(),))
backup_impl()
class ObjectOps(object):
"""Base class to manage list/add/modify/delete operations for set of
CA data objects."""
Add = '-a'
Delete = '-d'
List = '-l'
Modify = '-m'
OPS = (Add, Delete, List, Modify)
OP_NAMES = { Add: 'add', Delete: 'delete', List: 'list', Modify: 'modify' }
def __init__(self, model=None, type_desc=''):
self.model = model
self.type_desc = type_desc
self.options = ''
self.can_modify = False
self.add_if_needed = False
def run(self, argv):
"""Process subcommand arguments for a class of objects."""
op = None
opts, args = getopt(argv, 'adl' + (self.can_modify and 'mM' or '') + self.options)
other_opts = []
for k, v in opts:
if k == '-M':
self.add_if_needed = True
k = self.Modify
if k in self.OPS:
if op is not None:
raise UsageError('conflicting operations')
op = k
else:
other_opts.append((k, v))
if op is None:
op = self.List
op_name = self.OP_NAMES[op]
fn = getattr(self, op_name)
n_good = 0
n_bad = 0
if self.model:
g.verify_model(self.model)
else:
g.load_state()
mid_args = self.mid_parse(op, other_opts, args)
if op == self.List:
return self.list()
try:
for arg in mid_args:
try:
fn(arg)
n_good += 1
except Exception as e:
perr('%s %s: %s' % (op_name, arg, e))
n_bad += 1
finally:
if n_good:
try:
g.store_state()
except Exception as e:
perr('error while saving state: %s' % (e,))
try:
backup_impl()
except Exception as e:
perr('error while backing up: %s' % (e,))
if n_bad:
return n_good and 2 or 1
else:
return 0
def get_list_items(self):
return []
def mid_parse(self, op, opts, args):
"""Parse subcommand-specific options after the basic mode
options have been processed.
Return the list of arguments that processed for the specified
operation.
Base implementation just returns the input arguments.
"""
return args
def add(self, arg):
raise NotImplementedError()
def delete(self, arg):
raise NotImplementedError()
def list(self):
items = self.get_list_items()
if items:
pout('\n'.join(items))
pout('\n')
def modify(self, _):
raise Fail('can not modify %s objects' % (self.type_desc))
class SiteOps(ObjectOps):
def __init__(self):
super(SiteOps, self).__init__(model='ncf', type_desc='site')
def add(self, site):
"""Create an archive used to initialize the CA data directory at
a site.
The archive file contains a site-level CA certificate created by
this method and the root certificate bundle file that should be
used at the site.
"""
d = join(g.get_ident_dir(), 'site', site)
ident = Identity('site', site)
if g.has_ident(ident):
raise Fail("site '%s' already exists" % (site,))
init_empty_dir(d)
try:
site_ca_pfx = make_ca_cert('site-%s-ca' % (site,), g.get_ca_prefix(), join(d, site))
site_root_bundle = join(d, 'root-' + site + '.crt')
cat_files([site_ca_pfx + '.crt', g.get_root_bundle()], site_root_bundle)
g.add_ident(ident)
arch_name = join(d, make_init_bundle_name(site))
with ZipFile(arch_name, 'w') as fd:
for name, path in (
('ca.crt', site_ca_pfx + '.crt'),
('ca.key', site_ca_pfx + '.key'),
('root.crt', site_root_bundle)):
with open(path, 'rb') as fs:
fd.writestr(name, fs.read())
pinf('created site initialization bundle: ' + arch_name)
except:
safe_rmtree(d)
raise
def delete(self, site):
d = join(g.get_ident_dir(), 'site', site)
if exists(d):
rmtree(d)
else:
pwrn("site '%s' does not exist" % (site,))
def get_list_items(self):
return [ ident.name for ident in g.get_idents() if ident.type == 'site' ]
@subcommand('site',desc='Manage site CA certificates (for NCF)')
@usage(''' # [-l] List existing site certificates
# -a {site ID}... Generate new certificates for sites
# -d {site ID}... Delete site certificates''')
def site_cmd(argv):
return SiteOps().run(argv)
class DBUserOps(ObjectOps):
def __init__(self):
super(DBUserOps, self).__init__(model='site', type_desc='database user')
def add(self, name):
add_ident_impl('dbuser', name)
def delete(self, name):
if name not in BASELINE_DB_USERS:
remove_ident_impl('dbuser', name)
else:
raise Fail("database user '%s' is baseline and can not be deleted" % (name,))
def get_list_items(self):
return [ ident.name for ident in g.get_idents() if ident.type == 'dbuser' ]
@subcommand('dbuser', desc='Manage certificates for database accounts')
@usage(''' # [-l] List existing account certificates
# -a {database user ID}... Create new certificates for accounts
# -d {database user ID}... Delete account certificates''')
def dbuser_cmd(argv):
return DBUserOps().run(argv)
class UserOps(ObjectOps):
def __init__(self):
super(UserOps, self).__init__(model='site', type_desc='system user')
self.role = None
self.options = 'r:'
self.can_modify = True
def mid_parse(self, op, opts, args):
if op == self.Add:
self.role = 'user'
for k, v in opts:
if k == '-r':
self.role = v
if self.role:
g.verify_role(self.role)
return args
def add(self, user):
g.get_state().add_target('user', user, role=self.role, location_specs=[])
def delete(self, user):
if user not in BASELINE_USERS:
g.get_state().remove_target('user', user)
else:
raise Fail("user '%s' is baseline and can not be deleted")
def get_list_items(self):
return [ target.name + ' : ' + target.role for target in g.get_state().get_targets()
if target.type == 'user' ]
def modify(self, user):
if self.role:
if self.add_if_needed and \
not g.get_state().has_target('user', user):
self.add(user)
target = g.get_state().get_target('user', user)
target.role = self.role
@subcommand('user', desc='Manage system users who need access to certificates')
@usage(''' # [-l] List registered users
# -a [-r role] {system user ID}... Register new users (role defaults to 'user')
# -m|-M [-r role] {system user ID}... Change role of specified users (-M registers if needed)
# -d {system user ID}... Unregister users
In addition to user IDs, a group account can be specified as "@groupname".
This will be expanded to a list of users during a refresh, excluding any
users which are explicitly registered.''')
def user_cmd(argv):
return UserOps().run(argv)
class RoleOps(ObjectOps):
Set = 'set'
def __init__(self):
super(RoleOps, self).__init__(model='site', type_desc='role')
self.dbuser_list = []
self.can_modify = True
self.item_op = None
self.options = 'ADS'
def mid_parse(self, op, opts, args):
"""Handles role-specific options.
Unlike most object commands, the role command can only operate
on a single role at a time. Additional arguments are
interpreted as a list of database user identities.
"""
for k, _ in opts:
if k == '-A':
self.item_op = self.Add
elif k == '-D':
self.item_op = self.Delete
elif k == '-S':
self.item_op = self.Set
if op != self.Modify and self.item_op:
raise UsageError('-A/-D/-S options can only be used with modify (-m) operation')
elif op == self.Modify and not self.item_op:
raise UsageError('missing database user list operation (-A/-D/-S)')
self.dbuser_list = args[1:]
return args[0:1]
def validate_ident_specs(self, ident_specs):
for spec in ident_specs:
if not g.has_ident(Identity(* spec.split(':', 1))):
raise Fail("unknown identity '%s'" % (spec,))
def add(self, name):
new_role_idents = [ make_spec('dbuser', dbuser) for dbuser in self.dbuser_list ]
self.validate_ident_specs(new_role_idents)
g.get_state().add_role(name, new_role_idents)
def delete(self, name):
if name not in BASELINE_ROLES:
g.get_state().remove_role(name)
else:
raise Fail("role '%s' is baseline and can not be deleted" % (name,))
def get_list_items(self):
def fmt_ident(ident):
pfx = 'dbuser:'
s = str(ident)
return s[len(pfx):] if s.startswith(pfx) else s
return [ name + ' : ' + ' '.join([fmt_ident(ident) for ident in idents])
for name, idents in g.get_state().get_roles().iteritems()
if name not in INTERNAL_ROLES ]
def modify(self, name):
def make_unique(l):
ol = []
seen = set()
for i in l:
if i not in seen:
seen.add(i)
ol.append(i)
return ol
if name not in INTERNAL_ROLES:
st = g.get_state()
if self.add_if_needed and name not in st.get_roles():
st.add_role(name, [])
role_idents = [ ident.get_spec() for ident in st.get_role_idents(name) ]
new_role_idents = [ make_spec('dbuser', dbuser) for dbuser in self.dbuser_list ]
if self.item_op == self.Add:
new_role_idents = role_idents + new_role_idents
elif self.item_op == self.Delete:
for ident in new_role_idents:
try:
role_idents.remove(ident)
except ValueError:
pass # ignore
new_role_idents = role_idents
elif self.item_op == self.Set:
pass
new_role_idents = make_unique(new_role_idents)
if self.item_op in (self.Add, self.Set):
self.validate_ident_specs(new_role_idents)
if name in BASELINE_ROLE_DB_USERS:
for dbuser in BASELINE_ROLE_DB_USERS[name]:
spec = make_spec('dbuser', dbuser)
if spec not in new_role_idents:
pwrn("can not remove database user '%s' from role '%s'" % (dbuser, name))
new_role_idents.append(spec)
st.set_role_ident_specs(name, new_role_idents)
else:
raise Fail('unmodifiable role')
@subcommand('role', 'Manage lists of database accounts')
@usage(''' # [-l]
List roles
# -a {role name} {database user ID}...
Add new role containing the specified database user IDs
# -m|-M -A|-D|-S {role name} {database user ID}...
Modify the given role, adding (-A), deleting (-D), or setting
the exact list of (-S) specified database user IDs
With -M, add the role if it does not exist
# -d {role name}...
Delete the specified roles''')
def role_cmd(argv):
return RoleOps().run(argv)
REFRESH_AND_CLEAR_OPTIONS='''
Options:
-n Perform a dry run and show rsync commands that would be run
-v Be verbose
-N rsync dry run (pass "-n" to rsync)'''
@subcommand(desc='Install certs/keys to user and application directories')
@usage(' # [options]' + REFRESH_AND_CLEAR_OPTIONS)
def refresh(argv, clear_files=False):
dry_run = False
verbose = False
rsync_dry_run = False
opts, args = getopt(argv, 'nvN')
for k, _ in opts:
if k == '-n':
dry_run = True
elif k == '-v':
verbose = True
elif k == '-N':
rsync_dry_run = True
refresh_impl(args, dry_run=dry_run, rsync_dry_run=rsync_dry_run,
verbose=verbose, clear_files=clear_files)
def expand_user_groups(targets):
"""Expand group targets to individual user targets.
Expand any targets of the form user:@group in the list to
individual user:username targets. If a group member is
already in the list of targets, it is not added.
Note that this handles primary and secondary group assignments.
"""
try:
all_users = getpwall()
except Exception as e:
perr("error retrieving system user information: %s" % (e,))
return targets
seen = set()
result = []
groups_to_expand = []
for target in targets:
if target.type == 'user' and target.name[0:1] == '@':
groups_to_expand.append(target)
else:
result.append(target)
seen.add(target.get_spec())
for group_target in groups_to_expand:
try:
gr_ent = getgrnam(group_target.name[1:])
users_in_group = list(gr_ent.gr_mem)
for pw_ent in all_users:
if pw_ent.pw_gid == gr_ent.gr_gid:
users_in_group.append(pw_ent.pw_name)
except Exception as e:
perr("error retrieving group information: %s" % (e,))
continue
for user in users_in_group:
target = Target()
target.type = 'user'
target.name = user
target.role = group_target.role
spec = target.get_spec()
if spec not in seen:
seen.add(spec)
result.append(target)
return result
def refresh_impl(args, dry_run=False, rsync_dry_run=False,
clear_files=False, verbose=False):
"""Install/uninstall certificate key files to user and application
directories.
For each storage location, use rsync to add/remove certificate files
so that the location contains the set of files appropriate for
the target's role. For user targets, creates postgresql.*
symbolic links to awips.* files if they are installed.
args -- Optional list of target specs. These can use shell-style
wildcards. If empty, all known targets are refreshed. Note
that a user:username spec will not match a user:@group target
that includes the user.
clear_files -- If True, remove all certificate/key files from the
targets.
dry_run -- Only print targets that would be refreshed.
rsync_dry_run -- Run rsync with its "-n" option.
verbose -- Print more information and pass the "-v" option
to rsync if it is run.
"""
g.verify_model('site')
st = g.get_state()
if args:
targets_to_refresh = set()
for target_pattern in args:
matched_one = False
for target in st.get_targets():
if fnmatch(target.get_spec(), target_pattern):
targets_to_refresh.add(target)
matched_one = True
if not matched_one:
pwrn("pattern '%s' did not match any targets" % (target_pattern,))
targets_to_refresh = list(targets_to_refresh)
else:
targets_to_refresh = st.get_targets()
targets_to_refresh = expand_user_groups(targets_to_refresh)
is_root = geteuid() == 0
tmpdir = mkdtemp()
staging_dir = join(tmpdir, 'work')
paths_by_host = {}
bad_idents = set()
rsync_template = ['rsync', '-a', '--no-r', '--dirs', '--delete'] + \
[ '--include=*' + sfx for sfx in ALL_CRED_SUFFIXES ] + \
['--exclude=*']
if rsync_dry_run:
rsync_template.append('-n')
if verbose:
rsync_template.append('-v')
try:
for target in targets_to_refresh:
try:
policy = g.get_policy_for_target(target)
owner = target.owner
if owner is None and target.type == 'user':
owner = target.name
if owner is None:
raise Fail("could not determine owner for target '%s'")
if is_root:
pw_ent = getpwnam(owner)
uid = pw_ent.pw_uid
gid = pw_ent.pw_gid
else:
uid = -1
gid = -1
if exists(staging_dir):
rmtree(staging_dir)
mkdir(staging_dir, PRIVATE_DIR_PERM)
if is_root:
chown(staging_dir, uid, gid)
if not clear_files:
if target.role != SERVER_ROLE:
idents = st.get_role_idents(target.role)
else:
idents = [ Identity('server', target.name) ]
if verbose:
pinf("refresh '%s' identities: '%s'" % (target, idents))
for ident in idents:
if not g.has_ident(ident) and ident not in bad_idents:
bad_idents.add(ident)
pwrn("invalid identity '%s'" % (ident,))
continue
name = ident.name
abs_src_pfx = join(g.get_ident_dir(), ident.type, name)
dst_name = name if ident.type != 'server' else 'server'
for sfx in policy.java_keys and JDBC_CRED_SUFFIXES or LIBPQ_CRED_SUFFIXES:
dst_path = join(staging_dir, dst_name + sfx)
copyfile(abs_src_pfx + sfx, dst_path)
if sfx in ALL_KEY_SUFFIXES:
chmod(dst_path, PRIVATE_FILE_PERM)
if is_root:
chown(dst_path, uid, gid)
if policy.link_awips and ident.type == 'dbuser' and ident.name == 'awips':
symlink('awips' + sfx, join(staging_dir, 'postgresql' + sfx))
dst_path = join(staging_dir, 'root.crt')
copyfile(g.get_root_bundle(), dst_path)
if is_root:
chown(dst_path, uid, gid)
for host, path in resolve_target_locations(target):
try:
if host not in paths_by_host:
paths_by_host[host] = set()
if path in paths_by_host[host]:
pwrn("path '%s' on %s is claimed by multiple targets" %
(path, host and host or 'the local host'))
paths_by_host[host].add(path)
parent_dir = dirname(path)
if target.type == 'user' and not host and not isdir(parent_dir):
pwrn("target '%s': directory %s does not exist" % (target, parent_dir))
continue
if verbose:
pinf("refresh '%s' destination: '%s:%s'" % (target, host, path))
# Unfortunately, we can't use rsync to enforce policy.create_dir as it
# will always create the destination directory.
sanity_check_target_directory(path)
rsync_dest = (host and (host + ':') or '') + path + '/'
cmd = rsync_template + [staging_dir + '/', rsync_dest]
if not dry_run:
run(cmd, echo_stdout=verbose, echo_stderr_filtered=True)
else:
pinf(' '.join(cmd))
except Exception as e:
perr('refreshing %s:%s: %s' % (host, path, e))
except Exception as e:
perr("refreshing target '%s': %s" % (target.get_spec(), e))
finally:
safe_rmtree(tmpdir)
@subcommand(desc='Uninstall certs/keys from user and application directories')
@usage(' # [options]' + REFRESH_AND_CLEAR_OPTIONS)
def clear(argv):
refresh(argv, clear_files=True)
@subcommand(desc='Sync CA data directory to backup locations')
def backup(argv):
_, _ = getopt(argv, '')
backup_impl(False)
def backup_impl(auto=True):
"""Back up the CA data directory to the list of configured hosts."""
import socket
host_name = socket.gethostname()
g.load_state()
backup_targets = [ backup_spec for backup_spec in g.get_state().backups
if backup_spec != host_name ]
if backup_targets:
path = g.get_data_dir() + '/'
sanity_check_target_directory(path)
for backup_spec in backup_targets:
try:
run(['rsync', '-a', '--delete', path, backup_spec + ':' + path],
echo_stderr_filtered=True)
pinf('backed up to ' + backup_spec)
except Exception as e:
perr("attempting to back up to '%s': %s" % (backup_spec, e))
elif not auto:
pwrn(g.get_state().backups and 'no targets to back up to' or 'no backup targets defined')
def show_usage():
col_width = max([len(k) for k in sub_commands])
fmt = '%%-%ds %%s' % (col_width,)
sc = [ fmt % (k, getattr(v, 'desc', None) or '')
for k, v in sub_commands.iteritems() ]
sc.sort()
pname = basename(sys.argv[0])
pout('''Usage: %s [global option...] {command} [sub-option...] [arg...]
Global options:
-D {dir} Override the data directory [/etc/pki/a2pgca]
-R Allow running without being root
-h Display help (on a command, if specified)
Commands:
%s
''' % (pname, '\n '.join(sc)))
def main():
require_root = True
help_mode = False
try:
opts, args = getopt(sys.argv[1:], 'D:Rh')
for k, v in opts:
if k == '-D':
g.data_dir = v
elif k == '-R':
require_root = False
elif k == '-h':
help_mode = True
except GetoptError as e:
perr(str(e))
show_usage()
sys.exit(1)
if len(args):
subcommand_f = sub_commands.get(args[0])
if subcommand_f is None:
perr("unknown command '%s'" % (args[0],))
show_usage()
sys.exit(1)
if help_mode:
show_subcommand_usage(subcommand_f)
sys.exit(0)
try:
if require_root and geteuid() != 0:
raise Fail('must be root or use the -R option')
sys.exit(subcommand_f(args[1:]) or 0)
except (EnvironmentError, Fail) as e:
perr(str(e))
sys.exit(1)
else:
show_usage()
sys.exit(0 if help_mode else 1)
if __name__ == '__main__':
main()