diff --git a/setup.py b/setup.py index 8afac77..9988630 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,9 @@ setup( author_email="unit@paranoici.org", package_dir={"": "src"}, packages=find_packages("src"), - entry_points={"console_scripts": ["phid=phi.web.app:cli"]}, + entry_points={ + "console_scripts": ["phid=phi.web.app:cli", "phiadm=phi.cli.adm:cli"] + }, setup_requires=["pytest-runner"], install_requires=[ "aiohttp==3.8.1", diff --git a/src/phi/cli/__init__.py b/src/phi/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/phi/cli/adm.py b/src/phi/cli/adm.py new file mode 100644 index 0000000..16f7ffb --- /dev/null +++ b/src/phi/cli/adm.py @@ -0,0 +1,119 @@ +# -*- encoding: utf-8 -*- +import importlib.resources +from fnmatch import fnmatch +import re + +import click + +from phi.cli.utils import generate_from_templates +from phi.security import hash_pass + + +SERVICE_RE = re.compile(r"^(.+?):(.+)$") + +templates = [ + f.name.strip(".j2") + for f in importlib.resources.files("phi.cli.templates").iterdir() + if not f.name.endswith(".py") +] + + +def debug(ctx, out): + if ctx.obj["debug"]: + click.echo(f"DEBUG: {out}", err=True) + + +def validate_services(ctx, _, value): + for service in value: + if not SERVICE_RE.match(service): + click.echo(f"Unparsable service '{service}' - must match /^(.+?):(.+)$/") + ctx.exit() + + return value + + +@click.group( + name="phiadm", help="This cli may be used to interact with a local phid instance" +) +@click.option("-d", "--debug", is_flag=True, help="Enable debugging information") +@click.pass_context +def cli(ctx, debug): + ctx.ensure_object(dict) + ctx.obj["debug"] = debug + + +@cli.command( + "generate", + help="The base name of this LDAP directory (e.g. 'dc=example,dc=com')", +) +@click.option( + "-t", + "--template", + type=click.STRING, + multiple=True, + help=f"Name of the template (allowed: {', '.join(templates)};" + + " accepts also glob-like patterns)", +) +@click.option( + "-s", + "--default-service", + type=click.STRING, + multiple=True, + callback=validate_services, + help="A pair : representing a service (and the associated password)" +) +@click.option( + "--root-password", + type=click.STRING, + required=True, + help="The cleartext password for the root user", +) +@click.option( + "--phi-password", + type=click.STRING, + required=True, + help="The cleartext password for the phi service", +) +@click.argument( + "base_dn", + type=click.STRING, +) +@click.pass_context +def generate(ctx, template, default_service, root_password, phi_password, base_dn): + config = get_config(base_dn, phi_password, root_password) + debug(ctx, f"default_service: {default_service}") + if default_service: + add_default_services(config, default_service) + debug(ctx, f"config: {config}") + debug(ctx, f"templates: {templates}") + + if not template: + template = templates + + for name, content in generate_from_templates(config): + debug(ctx, f"current template: {name}") + if any(fnmatch(name, t) for t in template): + click.echo(content) + + +def get_config(base_dn, phi_password, root_password): + config = {"default_services": []} + config["base_dn"] = base_dn + config["phi_password"] = hash_pass(phi_password) + config["root_password"] = hash_pass(root_password) + + return config + + +def add_default_services(config, services): + for service in services: + user, password = parse_service(service) + config["default_services"].append( + {"name": user, "password": hash_pass(password)}) + + +def parse_service(service): + res = SERVICE_RE.search(service).groups() + if len(res) != 2: + raise ValueError(res) + return res[0], res[1] diff --git a/src/phi/cli/templates/00-base.ldif.j2 b/src/phi/cli/templates/00-base.ldif.j2 new file mode 100644 index 0000000..1a58d27 --- /dev/null +++ b/src/phi/cli/templates/00-base.ldif.j2 @@ -0,0 +1,23 @@ +version: 1 + +dn: {{ base_dn }} +objectClass: organization +objectClass: dcObject +dc: unit +o: Unit + +dn: ou=Hackers,{{ base_dn }} +objectClass: organizationalUnit +objectClass: top +ou: Hackers + +dn: ou=Robots,{{ base_dn }} +objectClass: top +objectClass: organizationalUnit +ou: Robots + +dn: ou=Roles,{{ base_dn }} +objectClass: top +objectClass: organizationalUnit +ou: Roles + diff --git a/src/phi/cli/templates/10-users.ldif.j2 b/src/phi/cli/templates/10-users.ldif.j2 new file mode 100644 index 0000000..17071f1 --- /dev/null +++ b/src/phi/cli/templates/10-users.ldif.j2 @@ -0,0 +1,17 @@ +version: 1 + +dn: uid=phi,ou=Robots,{{ base_dn }} +objectClass: account +objectClass: simpleSecurityObject +objectClass: top +uid: phi +userPassword: {{ phi_password }} +{%- for service in default_services %} + +dn: uid={{ service.name }},ou=Robots,{{ base_dn }} +objectClass: account +objectClass: simpleSecurityObject +objectClass: top +uid={{ service.name }} +userPassword={{ service.password }} +{%- endfor %} diff --git a/src/phi/cli/templates/20-roles.ldif.j2 b/src/phi/cli/templates/20-roles.ldif.j2 new file mode 100644 index 0000000..5c8c086 --- /dev/null +++ b/src/phi/cli/templates/20-roles.ldif.j2 @@ -0,0 +1,6 @@ +version: 1 + +dn: cn=Admins,ou=Roles,{{ base_dn }} +cn: Admins +objectClass: groupOfNames +objectClass: top diff --git a/src/phi/cli/templates/99-acl.ldif.j2 b/src/phi/cli/templates/99-acl.ldif.j2 new file mode 100644 index 0000000..2a05dda --- /dev/null +++ b/src/phi/cli/templates/99-acl.ldif.j2 @@ -0,0 +1,14 @@ +access to dn.base="" + by * read + by group.exact="cn=Admins,{{ base_dn }}" manage + +access to attrs=entry + by * read + +access to attrs=userPassword + by self write + by anonymous auth + +access to dn.subtree="ou=Hackers,{{ base_dn }}" + by self write + by dn.subtree="ou=Services,{{ base_dn }}" read diff --git a/src/phi/cli/templates/__init__.py b/src/phi/cli/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/phi/cli/templates/slapd.conf.j2 b/src/phi/cli/templates/slapd.conf.j2 new file mode 100644 index 0000000..b2417c2 --- /dev/null +++ b/src/phi/cli/templates/slapd.conf.j2 @@ -0,0 +1,87 @@ +####################################################################### +# Modules +####################################################################### + +include /etc/openldap/schema/core.schema +include /etc/openldap/schema/cosine.schema +include /etc/openldap/schema/corba.schema +include /etc/openldap/schema/inetorgperson.schema +include /etc/openldap/schema/nis.schema +include /etc/openldap/schema/collective.schema +include /etc/openldap/schema/openldap.schema + +modulepath /usr/lib/openldap +moduleload back_mdb +moduleload refint +moduleload memberof + + +####################################################################### +# Core +####################################################################### + +pidfile /var/slapd/slapd.pid +argsfile /var/slapd/slapd.args +loglevel conns + +serverID 0 + + +####################################################################### +# Security +####################################################################### + +#TLSCACertificateFile /var/slapd/fullchain.pem +TLSCertificateFile /var/slapd/cert.pem +TLSCertificateKeyFile /var/slapd/key.pem +TLSCipherSuite HIGH + +# Sample security restrictions +# Define global ACLs to disable default read access. +# Require integrity protection (prevent hijacking) +# Require 112-bit (3DES or better) encryption for updates +# Require 63-bit encryption for simple bind +security ssf=1 simple_bind=256 update_ssf=256 + + +####################################################################### +# MDB database definitions +####################################################################### + +database mdb +maxsize 1073741824 +suffix "{{ base_dn }}" + +# Overlays to be loaded for the database. +overlay memberof + +# Cleartext passwords, especially for the rootdn, should +# be avoid. See slappasswd(8) and slapd.conf(5) for details. +# Use of strong authentication encouraged. +rootdn "cn=root,{{ base_dn }}" +rootpw {{ root_password }} + +# The database directory MUST exist prior to running slapd AND +# should only be accessible by the slapd and slap tools. +# Mode 700 recommended. +directory /var/slapd +mode 0700 + +password-hash {CRYPT} +password-crypt-salt-format "$6$%.16s" + +# Indices to maintain +index objectClass pres,eq +index uid,cn,sn,mail eq,sub +index memberof pres,eq + + +####################################################################### +# MemberOf configuration +####################################################################### + +memberof-group-oc groupOfNames +memberof-memberof-ad memberOf +memberof-member-ad member +memberof-dangling error +memberof-refint true diff --git a/src/phi/cli/utils.py b/src/phi/cli/utils.py new file mode 100644 index 0000000..78722f8 --- /dev/null +++ b/src/phi/cli/utils.py @@ -0,0 +1,25 @@ +# -*- encoding: utf-8 -*- +import os.path + +from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader + + +def get_jinja_env(): + pkg_path_templates = os.path.realpath(os.path.join(__name__, "./templates")) + loaders = [ + FileSystemLoader(pkg_path_templates), + PackageLoader("phi.cli", package_path="templates"), + ] + return Environment(loader=ChoiceLoader(loaders)) + + +def generate_from_templates(config): + env = get_jinja_env() + + for t in env.list_templates(extensions="j2"): + tmpl = env.get_template(t) + content = tmpl.render(**config) + # This is loaded from a resource and won't be None + name = tmpl.name.strip(".j2") + # name = os.path.basename(tmpl.filename).strip(".j2") + yield (name, content)